extends Node
## HEADLESS GATE — texture packs reach the level (hyper-games #94 P1 step 3, design
## gates 7-8): pack.json catalog -> TexturePacks.build (1024 / 512 arrays, missing
## checker) -> LevelPackMaterial on a real DCLevel fed through the journal ->
## derived projection rows point at the right array layer.
##
## RUN AS A SCENE (level_persistence.gd and settings name autoloads at compile time):
## "$HYPER_TPS_GODOT" --headless res://scenes/test/pack_texture_gate.tscn
var fails := 0
var checks := 0
func _ok(what: String, cond: bool, note: String = "") -> void:
checks += 1
if not cond:
fails += 1
print(" %-64s %s %s" % [what, "OK" if cond else "**FAIL**", note])
func _box(lo: Vector3, hi: Vector3) -> Array:
return [1, 0, 0, hi.x, -1, 0, 0, -lo.x, 0, 1, 0, hi.y, 0, -1, 0, -lo.y, 0, 0, 1, hi.z, 0, 0, -1, -lo.z]
func _ready() -> void:
await get_tree().process_frame
await _run()
if checks < 25:
print(" **FAIL** only %d checks ran -- setup died before the assertions" % checks)
fails += 1
print("\nGATE pack_texture: %s (%d checks, %d failed)" % ["PASS" if fails == 0 else "FAIL", checks, fails])
get_tree().quit(0 if fails == 0 else 1)
func _run() -> void:
print("-- catalog --")
var cat: Dictionary = TexturePacks.catalog()
var per := {}
for id: String in cat:
var p := id.get_slice("/", 0)
per[p] = int(per.get(p, 0)) + 1
_ok("ambientcg: 12 textures", int(per.get("ambientcg", 0)) == 12, str(per.get("ambientcg")))
_ok("polyhaven: 5 textures", int(per.get("polyhaven", 0)) == 5, str(per.get("polyhaven")))
_ok("kenney-prototype: 78 textures", int(per.get("kenney-prototype", 0)) == 78, str(per.get("kenney-prototype")))
_ok("librequake: 1101 textures", int(per.get("librequake", 0)) == 1101, str(per.get("librequake")))
_ok("trimsheets: 10 textures (2 of the catalogue's 12 excluded)", int(per.get("trimsheets", 0)) == 10,
str(per.get("trimsheets")))
_ok("composed: the industrial trim sheet", int(per.get("composed", 0)) == 1, str(per.get("composed")))
# Exactly the known packs: a new pack must be added here on purpose.
_ok("no Quake (id) textures in the catalog -- they stay out of releases",
not per.has("quake-test") and per.size() == 6, str(per.keys()))
_ok("default builder texture is the first Kenney grid",
TexturePacks.default_id() == "kenney-prototype/dark_01", TexturePacks.default_id())
var bricks: Dictionary = TexturePacks.entry("ambientcg/Bricks105")
_ok("per-axis tile size from pack.json (Bricks105 2.4 x 1.2 m)",
is_equal_approx(float(bricks.get("tile_u_m", 0)), 2.4) and is_equal_approx(float(bricks.get("tile_v_m", 0)), 1.2))
print("\n-- build at 1024 --")
var ids := PackedStringArray(["ambientcg/Bricks105", "librequake/lq_tech/aqconc03",
"kenney-prototype/dark_01", "ambientcg/Metal041B", "nope/never"])
var b: Dictionary = TexturePacks.build(ids, 1024)
var alb: Texture2DArray = b.get("albedo")
var nrm: Texture2DArray = b.get("norm")
var orm: Texture2DArray = b.get("orm")
_ok("three arrays came back", alb != null and nrm != null and orm != null)
if alb == null or nrm == null or orm == null:
return
_ok("layers = missing + 4 known", alb.get_layers() == 5 and nrm.get_layers() == 5 and orm.get_layers() == 5,
str(alb.get_layers()))
_ok("1024 x 1024 layers", alb.get_width() == 1024 and alb.get_height() == 1024)
_ok("mipmapped (sampler is *_mipmap)", alb.has_mipmaps() and nrm.has_mipmaps() and orm.has_mipmaps())
var info: Dictionary = b.get("info", {})
_ok("info for the four known ids, layers 1..4", info.size() == 4
and int(info["ambientcg/Bricks105"]["layer"]) == 1 and int(info["ambientcg/Metal041B"]["layer"]) == 4, str(info.keys()))
_ok("unknown id reported, not given a layer", not info.has("nope/never")
and (b.get("unknown", PackedStringArray()) as PackedStringArray).has("nope/never"))
_ok("missing layer is 0", int(b.get("missing_layer", -1)) == 0)
_ok("tile sizes carried into info", is_equal_approx(float(info["ambientcg/Bricks105"]["tile_u"]), 2.4)
and is_equal_approx(float(info["librequake/lq_tech/aqconc03"]["tile_u"]), 128 * 0.028))
# Pixels from build_images: a Texture2DArray cannot be read back headless.
var bi: Dictionary = TexturePacks.build_images(ids, 1024)
var miss: Image = (bi["albedo"] as Array)[0]
_ok("missing layer is a two-colour checker",
miss.get_pixel(0, 0) != miss.get_pixel(miss.get_width() / 8, 0), "%s vs %s" % [miss.get_pixel(0, 0), miss.get_pixel(miss.get_width() / 8, 0)])
var kn: Image = (bi["norm"] as Array)[3] # Kenney ships no normal map
var kc := kn.get_pixel(17, 17)
_ok("no normal map -> flat (0.5, 0.5, 1)", absf(kc.r - 0.5) < 0.02 and absf(kc.g - 0.5) < 0.02 and kc.b > 0.98, str(kc))
# LibreQuake ships albedo only; the fetch tool generates normals (DeepBump, native size).
var lq: Dictionary = TexturePacks.entry("librequake/lq_tech/aqconc03")
_ok("LibreQuake entry carries a GENERATED normal", String(lq.get("normal", "")) != ""
and String(lq.get("normal_generated", "")).begins_with("deepbump"), str(lq.get("normal_generated")))
var ln: Image = (bi["norm"] as Array)[2] # aqconc03, 128 px -> 1024
var lo := Vector2(1.0, 1.0)
var hi := Vector2(0.0, 0.0)
var blocks_ok := true
for by in range(0, 1024, 72):
for bx in range(0, 1024, 72):
var c := ln.get_pixel(bx, by)
lo = Vector2(minf(lo.x, c.r), minf(lo.y, c.g))
hi = Vector2(maxf(hi.x, c.r), maxf(hi.y, c.g))
var cell := Vector2i(bx / 8 * 8, by / 8 * 8) # its 8x8 block (one 128 px texel)
if ln.get_pixel(cell.x + 7, cell.y + 7) != ln.get_pixel(cell.x, cell.y):
blocks_ok = false
_ok("its normal layer is NOT the flat fallback", hi.x - lo.x > 0.1 and hi.y - lo.y > 0.1,
"R %.2f..%.2f G %.2f..%.2f" % [lo.x, hi.x, lo.y, hi.y])
_ok("one normal per source texel: 8x8 blocks at 1024 (nearest grid)", blocks_ok)
# Layer 0 is the missing layer, so ids[i] is layer i + 1. LibreQuake ships no ORM;
# Kenney has DERIVED maps since 2026-10-03 (fetch_texture_packs.kenney_derive).
var lo_orm := ((bi["orm"] as Array)[2] as Image).get_pixel(17, 17)
_ok("no ORM (LibreQuake) -> AO 1, rough 0.8, metal 0",
lo_orm.r > 0.98 and absf(lo_orm.g - 0.8) < 0.02 and lo_orm.b < 0.02, str(lo_orm))
var ko := ((bi["orm"] as Array)[3] as Image).get_pixel(17, 17)
_ok("Kenney derived ORM on the paint: AO 1, rough 0.65, metal 0",
ko.r > 0.98 and absf(ko.g - 0.65) < 0.02 and ko.b < 0.02, str(ko))
var mo: Image = (bi["orm"] as Array)[4]
var metal := 0.0
for y in range(0, 1024, 64):
for x in range(0, 1024, 64):
metal += mo.get_pixel(x, y).b
_ok("Metal041B ORM blue (metal) is high", metal / 256.0 > 0.5, "%.2f" % (metal / 256.0))
# POM tier: height rides in ORM ALPHA (white = top), 1.0 where a texture has none.
var bo: Image = (bi["orm"] as Array)[1] # Bricks105 (has height.png)
var alo := 1.0
var ahi := 0.0
for y in range(0, 1024, 32):
for x in range(0, 1024, 32):
var av := bo.get_pixel(x, y).a
alo = minf(alo, av)
ahi = maxf(ahi, av)
_ok("Bricks105 ORM alpha carries its height map", bo.get_format() == Image.FORMAT_RGBA8 and ahi - alo > 0.3,
"alpha %.2f..%.2f" % [alo, ahi])
_ok("no height -> ORM alpha 1 (no relief)", ((bi["orm"] as Array)[3] as Image).get_pixel(17, 17).a > 0.99)
_ok("info carries the fitted relief depth (Bricks105 height_m)",
is_equal_approx(float(info["ambientcg/Bricks105"].get("height_m", -1.0)), 0.01075),
str(info["ambientcg/Bricks105"].get("height_m")))
print("\n-- build at 512 (Low preset) --")
var b5: Dictionary = TexturePacks.build(PackedStringArray(["ambientcg/Bricks105"]), 512)
var a5: Texture2DArray = b5.get("albedo")
_ok("512 x 512 layers", a5 != null and a5.get_width() == 512 and a5.get_height() == 512)
print("\n-- Settings: texture_res follows the preset --")
var settings: Node = get_tree().root.get_node_or_null("/root/Settings")
_ok("Settings autoload present", settings != null)
if settings != null:
settings.set_value("preset", 0)
_ok("Low -> 512", int(settings.resolved_value("texture_res")) == 512, str(settings.resolved_value("texture_res")))
settings.set_value("preset", 2) # Low / Medium / High since 2026-10-03
_ok("High -> 1024", int(settings.resolved_value("texture_res")) == 1024, str(settings.resolved_value("texture_res")))
_ok("High -> surface detail Parallax", int(settings.resolved_value("surface_detail")) == 1,
str(settings.resolved_value("surface_detail")))
settings.set_value("preset", 0)
_ok("Low -> surface detail Normal maps", int(settings.resolved_value("surface_detail")) == 0,
str(settings.resolved_value("surface_detail")))
settings.set_value("preset", 2)
print("\n-- LevelPackMaterial on a real DCLevel through the journal --")
if not ClassDB.class_exists("DCLevel"):
_ok("DCLevel registered", false)
return
var gs: Node = get_tree().root.get_node_or_null("/root/GameState")
gs.route("world:level:*", 1, "peer") # 1 == Channel.DURABLE
var keet: Node = get_tree().root.get_node_or_null("/root/KeetService")
if keet != null:
keet.current_member_id = "gate-author"
var level: Node3D = ClassDB.instantiate("DCLevel")
add_child(level)
var persist := LevelPersistence.new()
persist.level = level
add_child(persist)
await get_tree().process_frame
var mi := level.get_node_or_null("LevelMesh") as MeshInstance3D
_ok("DCLevel made its LevelMesh", mi != null)
if mi == null:
return
var lpm := LevelPackMaterial.new()
_ok("install succeeds on a DCLevel with the P1 API", lpm.install(level, mi, 1024))
var mat := mi.material_override as ShaderMaterial
_ok("the level wears dc_level.gdshader", mat != null and mat.shader != null
and mat.shader.resource_path == "res://scenes/maps/dc_level.gdshader")
if mat == null:
return
# Carved pockets are a second MeshInstance3D with the SAME vertex encoding
# (dc_level.cpp _build_pockets) — without the level material they render as
# Godot's default grey next to the textured wall they continue.
var pk := level.get_node_or_null("LevelPockets") as MeshInstance3D
_ok("carved pockets wear the level material too", pk != null and pk.material_override == mat,
"LevelPockets %s" % ("missing" if pk == null else str(pk.material_override)))
_ok("untextured faces are plain, not debug colours", bool(mat.get_shader_parameter("none_plain")))
var ft: Array = []
for i in 6:
ft.append({"tex": "ambientcg/Bricks105" if i != 2 else "nope/never", "mode": 1})
persist.record("brush.create", {
"planes": _box(Vector3(0, 0, 0), Vector3(2, 2, 2)), "proj": [], "material_id": 1,
"level_id": 0, "sky": false, "face_tex": ft})
persist.refold()
# sync() after the refold: it sees the new id, loads it, sets the info and
# rebuilds, so the derived rows carry Bricks105's real layer.
for f in 4:
await get_tree().process_frame
lpm.sync()
var tab: PackedFloat32Array = level.get_projection_table()
var rows := tab.size() / 16
_ok("table uploaded to the shader", bool(mat.get_shader_parameter("has_projection_table"))
and int(mat.get_shader_parameter("projection_rows")) == rows and rows > 0, "%d rows" % rows)
var lays := {}
for r in rows:
lays[int(tab[r * 16])] = true
var bl := int(lpm.info().get("ambientcg/Bricks105", {}).get("layer", -1))
_ok("Bricks105 loaded on demand", bl >= 1, str(bl))
_ok("its faces point at its layer", lays.has(bl), str(lays.keys()))
_ok("the unknown face points at the missing layer", lays.has(0), str(lays.keys()))
var la: Texture2DArray = mat.get_shader_parameter("tex_atlas")
_ok("albedo array bound, 1024", la != null and la.get_width() == 1024)
_ok("ORM path on", bool(mat.get_shader_parameter("use_orm")) and bool(mat.get_shader_parameter("use_pbr")))
print("\n-- surface detail tier: Parallax swaps the COMPILED shader, keeps the textures --")
lpm.set_detail(1)
_ok("Parallax -> dc_level_pom.gdshader", mat.shader != null
and mat.shader.resource_path == "res://scenes/maps/dc_level_pom.gdshader", mat.shader.resource_path if mat.shader else "null")
_ok("textures still bound after the swap", mat.get_shader_parameter("tex_atlas") != null
and mat.get_shader_parameter("orm_atlas") != null and bool(mat.get_shader_parameter("has_projection_table")))
var lp: Texture2D = mat.get_shader_parameter("layer_params")
_ok("per-layer relief depths uploaded", lp != null and lp.get_width() >= 2, str(lp))
_ok("pockets follow the swap", pk != null and pk.material_override == mat)
lpm.set_detail(0)
_ok("Normal maps -> back to dc_level.gdshader", mat.shader.resource_path == "res://scenes/maps/dc_level.gdshader")
print("\n-- switching to 512 rebuilds the arrays, layers kept --")
lpm.set_res(512)
for f in 3:
await get_tree().process_frame
lpm.sync()
var la5: Texture2DArray = mat.get_shader_parameter("tex_atlas")
_ok("albedo array now 512", la5 != null and la5.get_width() == 512)
_ok("Bricks105 keeps its layer", int(lpm.info().get("ambientcg/Bricks105", {}).get("layer", -1)) == bl)