Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
8a26313
[BUG] Orphan Node Leak from Placeholder Sprites in main_scene.gd #540
ikostan Apr 16, 2026
faa58f0
[QA] Orphan Node Leak Fix – Test Plan #549
ikostan Apr 16, 2026
4782dba
Update test/gut/test_main_scene_orphan_nodes.gd
ikostan Apr 16, 2026
49ac64a
Update main_scene.gd
ikostan Apr 16, 2026
7bfa831
Add orphan leak helper and update tests
ikostan Apr 16, 2026
1bfd05f
Update main_scene.gd
ikostan Apr 16, 2026
2a647aa
suggestion: Consider tightening the type of stats_panel instead of us…
ikostan Apr 16, 2026
3713757
Update test_main_scene_orphan_nodes.gd
ikostan Apr 16, 2026
437369f
Update test_player_lifecycle.gd
ikostan Apr 16, 2026
c36a488
Update main_scene.gd
ikostan Apr 16, 2026
5822274
Update test_player_lifecycle.gd
ikostan Apr 16, 2026
608020e
[FEATURE] Randomize rotation for organic decor sprites in main scene …
ikostan Apr 16, 2026
8eb7436
Update main_scene.gd
ikostan Apr 16, 2026
237b33b
Make the background look as random and organic as possible
ikostan Apr 16, 2026
4d999f4
Update main_scene.gd
ikostan Apr 16, 2026
95b2b9e
[FEATURE] Increase parallax background chunk size to prevent visual r…
ikostan Apr 16, 2026
d63252e
Add GUT tests for parallax chunk sizes
ikostan Apr 16, 2026
26f42a0
GUT test file dedicated specifically to verifying the randomized tran…
ikostan Apr 16, 2026
f38072c
Adjust bushes/decor layer height and density
ikostan Apr 16, 2026
cb04302
assertions that lock in the "Goldilocks Zone"
ikostan Apr 16, 2026
f7df782
Update test_main_scene_parallax_chunks.gd
ikostan Apr 16, 2026
221cbd6
Update scripts/main_scene.gd
ikostan Apr 16, 2026
60e24c2
Update main_scene.gd
ikostan Apr 16, 2026
909d4a1
Merge branch 'orphan-node-leak-from-placeholder-sprites-in-main_scene…
ikostan Apr 16, 2026
aaddd37
Update test_decor_layer_transformations.gd
ikostan Apr 16, 2026
7127f19
Rename performance test & add parallax checks
ikostan Apr 16, 2026
3740a4c
Improve test teardown and prevent orphan nodes
ikostan Apr 16, 2026
deb1ae2
Performance assertion is likely to be CI-flaky, and _process side eff…
ikostan Apr 16, 2026
2588460
suggestion: The hardcoded screens_tall = 8.0 is duplicated and would …
ikostan Apr 16, 2026
2f94791
Update gut_test_helper.gd
ikostan Apr 16, 2026
a1c6821
Update test_main_scene_parallax_and_performance.gd
ikostan Apr 16, 2026
b9cfc17
Use GutHelper.safe_hard_free in tests
ikostan Apr 16, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 34 additions & 18 deletions scripts/main_scene.gd
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,14 @@ extends Node2D

enum MessageType { CRITICAL_UNBOUND, KEY_PRESS_UNBOUND }

# At the top of main_scene.gd
@export var parallax_screens_tall: float = 8.0

var _showing_unbound_warning: bool = false
var _showing_unbound_key_message: bool = false

@onready var player: Node2D = $Player
# @onready var stats_panel: Panel = $PlayerStatsPanel
@onready var stats_panel: Variant = $PlayerStatsPanel
@onready var stats_panel: Panel = $PlayerStatsPanel
@onready var background: ParallaxBackground = $Background
@onready var bushes_layer: ParallaxLayer = $Background/Bushes # Reference to the bushes layer
Comment thread
sourcery-ai[bot] marked this conversation as resolved.
@onready var decor_layer: ParallaxLayer = $Background/Decor # Reference to the decor layer
Expand Down Expand Up @@ -116,22 +118,25 @@ func setup_bushes_layer(viewport: Vector2) -> void:
if not bushes_layer:
return

# Clear existing children
# Clear existing children (Safely detach first, then instantly destroy)
for child in bushes_layer.get_children():
bushes_layer.remove_child(child)
child.queue_free()
child.free()

# Get bush IDs from preloader (Array[String])
var bush_ids: Array = Array(texture_preloader.get_resource_list()).filter(
func(id: String) -> bool: return id.begins_with("bush_")
)
print("Loaded ", bush_ids.size(), " bush textures")

if bush_ids.is_empty():
return

var num_bushes: int = bush_ids.size()
var layer_height: float = viewport.y * 4
# THE GOLDILOCKS ZONE:
# 8 screens is the sweet spot for infinite illusion vs CPU overhead
var layer_height: float = viewport.y * parallax_screens_tall

# Drop density multiplier to match
var num_bushes: int = bush_ids.size() * 2

for i in range(num_bushes):
var bush: Sprite2D = Sprite2D.new()
Expand All @@ -152,29 +157,35 @@ func setup_bushes_layer(viewport: Vector2) -> void:
bushes_layer.motion_mirroring = Vector2(0, layer_height)


## Sets up the decor layer with random X positions, sizes, and textures.
## Sets up the decor layer with random X positions, sizes, textures, rotations, and flips.
## @param viewport: Vector2 - The viewport size.
## @return: void
func setup_decor_layer(viewport: Vector2) -> void:
if not decor_layer:
return

# Clear existing children
# Clear existing children (Safely detach first, then instantly destroy)
for child in decor_layer.get_children():
decor_layer.remove_child(child)
child.queue_free()
child.free()

# Get decor IDs from preloader (Array[String])
var decor_ids: Array = Array(texture_preloader.get_resource_list()).filter(
func(id: String) -> bool: return id.begins_with("decor_")
)
print("Loaded ", decor_ids.size(), " decor textures")

if decor_ids.is_empty():
return

var num_decors: int = decor_ids.size()
var layer_height: float = viewport.y * 4
# THE GOLDILOCKS ZONE:
# Match the bushes layer height
var layer_height: float = viewport.y * parallax_screens_tall

# Drop density multiplier to match
var num_decors: int = decor_ids.size() * 2

# Define strict rotation angles (0, 90, 180, -90 degrees)
var allowed_rotations: Array[float] = [0.0, 90.0, 180.0, -90.0]

for i in range(num_decors):
var decor: Sprite2D = Sprite2D.new()
Expand All @@ -183,9 +194,18 @@ func setup_decor_layer(viewport: Vector2) -> void:
decor.texture = texture_preloader.get_resource(id)
decor.centered = false

var scale_factor: float = randf_range(0.5, 1.0)
# SCALING TRICK 1: Wider scale range (0.5 to 1.5) for more size variance
var scale_factor: float = randf_range(0.5, 1.5)
decor.scale = Vector2(scale_factor, scale_factor)

# SCALING TRICK 2: Randomly mirror the sprite horizontally and/or vertically
decor.flip_h = randf() < 0.5
decor.flip_v = randf() < 0.5

# Apply random cardinal rotation to ALL decor sprites
var random_degrees: float = allowed_rotations.pick_random()
decor.rotation = deg_to_rad(random_degrees)

decor.position.x = randf_range(0, viewport.x - (decor.texture.get_width() * scale_factor))
decor.position.y = randf_range(
0, layer_height - (decor.texture.get_height() * scale_factor)
Expand Down Expand Up @@ -248,10 +268,6 @@ func show_message(text: String, type: MessageType = MessageType.CRITICAL_UNBOUND
match type:
MessageType.KEY_PRESS_UNBOUND:
_showing_unbound_key_message = false
# CRITICAL_UNBOUND: Do NOT reset here (once-per-session intent)
# Reset only when bindings are fixed
# (e.g., in key_mapping.gd _on_conflict_confirmed or reset)
# _showing_unbound_warning = false # ← commented out


## Public: Clears the unbound warning flag after fixes.
Expand Down
13 changes: 13 additions & 0 deletions test/gut/gut_test_helper.gd
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,19 @@ extends RefCounted

const PLAYER_SCRIPT_PATH: String = "res://scripts/player.gd"


## Helper to safely hard-free a node without causing engine crashes.
## Asserts the node is valid and safe to instantly destroy.
static func safe_hard_free(node: Node) -> void:
if not is_instance_valid(node) or node.is_queued_for_deletion():
return

if node.is_inside_tree():
node.get_parent().remove_child(node)

node.free()


## Dynamically constructs the node hierarchy required by player.gd.
## :rtype: Node
static func build_mock_player_scene() -> Node:
Expand Down
131 changes: 131 additions & 0 deletions test/gut/test_decor_layer_transformations.gd
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
## Copyright (C) 2026 Egor Kostan
## SPDX-License-Identifier: GPL-3.0-or-later
## test_decor_layer_transformations.gd
##
## GUT unit tests for verifying randomized rotations, scaling,
## and flipping applied to the main scene's decor layer.

extends "res://addons/gut/test.gd"

const GutHelper = preload("res://test/gut/gut_test_helper.gd")

var main_scene: MainScene
var viewport_mock: Vector2 = Vector2(1920, 1080)


func before_each() -> void:
await get_tree().process_frame
main_scene = preload("res://scenes/main_scene.tscn").instantiate()
add_child(main_scene)
await get_tree().process_frame

func after_each() -> void:
# Use the helper's static method
if is_instance_valid(main_scene):
GutHelper.safe_hard_free(main_scene)
await get_tree().process_frame


## test_decor_sprites_have_valid_cardinal_rotations |
## Verifies every decor sprite is snapped to exactly 0, 90, 180, or -90 degrees.
## :rtype: void
func test_decor_sprites_have_valid_cardinal_rotations() -> void:
gut.p("Testing: All decor sprites must use strict cardinal rotations.")

main_scene.setup_decor_layer(viewport_mock)

var active_sprites: Array[Node] = main_scene.decor_layer.get_children().filter(
func(c: Node) -> bool: return not c.is_queued_for_deletion()
)

var allowed_radians: Array[float] = [
0.0,
deg_to_rad(90.0),
deg_to_rad(180.0),
deg_to_rad(-90.0)
]

var invalid_rotations: Array[float] = []

for node in active_sprites:
var sprite := node as Sprite2D
var is_valid := false

# Check if the sprite's rotation matches any allowed radian (using approx to handle float drift)
for allowed_rad in allowed_radians:
if is_equal_approx(sprite.rotation, allowed_rad):
is_valid = true
break

if not is_valid:
invalid_rotations.append(sprite.rotation)

assert_eq(invalid_rotations.size(), 0, "Found decor sprites with non-cardinal rotations.")


## test_decor_sprites_have_valid_scale_ranges |
## Verifies every decor sprite scale falls between 0.5 and 1.5, and is uniformly scaled.
## :rtype: void
func test_decor_sprites_have_valid_scale_ranges() -> void:
gut.p("Testing: All decor sprites must be uniformly scaled between 0.5 and 1.5.")

main_scene.setup_decor_layer(viewport_mock)

var active_sprites: Array[Node] = main_scene.decor_layer.get_children().filter(
func(c: Node) -> bool: return not c.is_queued_for_deletion()
)

var out_of_bounds_scales: Array[Vector2] = []
var non_uniform_scales: Array[Vector2] = []

for node in active_sprites:
var sprite := node as Sprite2D
var s: Vector2 = sprite.scale

# Check bounds (using 0.49 and 1.51 to safely absorb floating point precision errors)
if s.x < 0.49 or s.x > 1.51:
out_of_bounds_scales.append(s)

# Check uniformity (x scale must equal y scale)
if not is_equal_approx(s.x, s.y):
non_uniform_scales.append(s)

assert_eq(out_of_bounds_scales.size(), 0, "Found decor sprites outside the 0.5 - 1.5 scale range.")
assert_eq(non_uniform_scales.size(), 0, "Found decor sprites with non-uniform (squished/stretched) scaling.")


## test_decor_sprites_have_boolean_flips |
## Verifies that flip_h and flip_v properties are actively being randomized.
## :rtype: void
func test_decor_sprites_have_boolean_flips() -> void:
gut.p("Testing: Decor sprites should successfully randomize horizontal and vertical flips.")

main_scene.setup_decor_layer(viewport_mock)

var active_sprites: Array[Node] = main_scene.decor_layer.get_children().filter(
func(c: Node) -> bool: return not c.is_queued_for_deletion()
)

var found_h_true: bool = false
var found_h_false: bool = false
var found_v_true: bool = false
var found_v_false: bool = false

for node in active_sprites:
var sprite := node as Sprite2D

# Track horizontal flip states
if sprite.flip_h:
found_h_true = true
else:
found_h_false = true

# Track vertical flip states
if sprite.flip_v:
found_v_true = true
else:
found_v_false = true

# Assert that our generation loop produced at least one of every state
assert_true(found_h_true and found_h_false, "Expected a randomized mix of true and false for horizontal flips.")
assert_true(found_v_true and found_v_false, "Expected a randomized mix of true and false for vertical flips.")
1 change: 1 addition & 0 deletions test/gut/test_decor_layer_transformations.gd.uid
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
uid://dn1cexpx24og0
103 changes: 103 additions & 0 deletions test/gut/test_main_scene_orphan_nodes.gd
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
## Copyright (C) 2026 Egor Kostan
## SPDX-License-Identifier: GPL-3.0-or-later
## test_main_scene_orphan_nodes.gd

extends "res://addons/gut/test.gd"

const GutHelper = preload("res://test/gut/gut_test_helper.gd")

var main_scene: MainScene
var viewport_mock: Vector2 = Vector2(1920, 1080)

func before_each() -> void:
await get_tree().process_frame
main_scene = preload("res://scenes/main_scene.tscn").instantiate()
add_child(main_scene)
await get_tree().process_frame

func after_each() -> void:
# Use the helper's static method
if is_instance_valid(main_scene):
GutHelper.safe_hard_free(main_scene)
await get_tree().process_frame

func verify_no_orphan_leaks(baseline_orphans: int, context: String) -> void:
var current_orphans: int = Performance.get_monitor(Performance.OBJECT_ORPHAN_NODE_COUNT)
assert_eq(current_orphans, baseline_orphans, context)

func test_teardown_memory_sync() -> void:
var baseline_orphans: int = Performance.get_monitor(Performance.OBJECT_ORPHAN_NODE_COUNT)

main_scene.setup_bushes_layer(viewport_mock)
main_scene.setup_decor_layer(viewport_mock)

await get_tree().process_frame

# FIX: Free manually AND nullify so after_each ignores it
GutHelper.safe_hard_free(main_scene)
main_scene = null

await get_tree().process_frame
verify_no_orphan_leaks(baseline_orphans, "Expected orphans to return to baseline.")

func test_repeated_setup_call_stability() -> void:
var baseline_orphans: int = Performance.get_monitor(Performance.OBJECT_ORPHAN_NODE_COUNT)
for i in range(50):
main_scene.setup_bushes_layer(viewport_mock)
await get_tree().process_frame
verify_no_orphan_leaks(baseline_orphans, "No accumulated orphans after 50 calls.")

## Immediate Rebuild Integrity Test |
func test_immediate_rebuild_integrity() -> void:
await get_tree().process_frame
main_scene.setup_bushes_layer(viewport_mock)

# Added -> bool to the lambda
var initial_active_count: int = main_scene.bushes_layer.get_children().filter(
func(c: Node) -> bool: return not c.is_queued_for_deletion()
).size()

main_scene.setup_bushes_layer(viewport_mock)

# Added -> bool to the lambda
var new_active_count: int = main_scene.bushes_layer.get_children().filter(
func(c: Node) -> bool: return not c.is_queued_for_deletion()
).size()

assert_eq(new_active_count, initial_active_count, "Child count should remain stable.")

func test_layer_isolation() -> void:
main_scene.setup_bushes_layer(viewport_mock)
main_scene.setup_decor_layer(viewport_mock)
await get_tree().process_frame

var initial_decor: int = main_scene.decor_layer.get_child_count()
main_scene.setup_bushes_layer(viewport_mock)
await get_tree().process_frame
assert_eq(main_scene.decor_layer.get_child_count(), initial_decor, "Decor count stable.")

func test_scene_reload_lifecycle() -> void:
var baseline_orphans: int = Performance.get_monitor(Performance.OBJECT_ORPHAN_NODE_COUNT)

# FIX: Use safe_hard_free and nullify baseline scene
GutHelper.safe_hard_free(main_scene)
main_scene = null
await get_tree().process_frame

var reloaded_scene: MainScene = preload("res://scenes/main_scene.tscn").instantiate()
add_child(reloaded_scene)
reloaded_scene.setup_bushes_layer(viewport_mock)
await get_tree().process_frame

# Clean up the reloaded instance manually too
GutHelper.safe_hard_free(reloaded_scene)
await get_tree().process_frame
verify_no_orphan_leaks(baseline_orphans, "Clean teardown after reload.")

func test_stress_input_simulation() -> void:
var baseline_orphans: int = Performance.get_monitor(Performance.OBJECT_ORPHAN_NODE_COUNT)
for i in range(10):
main_scene.setup_bushes_layer(viewport_mock)
main_scene.setup_decor_layer(viewport_mock)
await get_tree().process_frame
verify_no_orphan_leaks(baseline_orphans, "Stable memory after stress.")
1 change: 1 addition & 0 deletions test/gut/test_main_scene_orphan_nodes.gd.uid
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
uid://c63067f3sp0l4
Loading
Loading