extends SceneTree var failures := 0 func _initialize() -> void: call_deferred("run") func check(condition: bool, message: String) -> void: if not condition: failures += 1 push_error(message) func rendered() -> void: for i in range(3): await process_frame await RenderingServer.frame_post_draw func pixel_world(panel: Node3D, pixel: Vector2) -> Vector3: return panel.to_global(Vector3((pixel.x / 1150.0 - .5) * .92,(.5 - pixel.y / 900.0) * .72,.012)) func run() -> void: _verify_bindings() _verify_native_fonts() var camera := Camera3D.new() camera.position = Vector3(0,0,3) camera.current = true root.add_child(camera) var panel: Node3D = load("res://scripts/controller_panel.gd").new() root.add_child(panel) var menu := [{"label":"JUMP","position":Vector2(-.22,.235)}, {"label":"SHIP","position":Vector2(.22,.235)}, {"label":"SHORTCUTS","position":Vector2(-.22,.155)}, {"label":"TACTICAL VIEW","position":Vector2(.22,.155)}, {"label":"CONTROLS / HELP","position":Vector2(-.22,.075)}, {"label":"RECENTER","position":Vector2(.22,.075)}] panel.set_navigation("NAVIGATION",menu,true) var revision: int = panel.content_revision panel.set_navigation("NAVIGATION",menu.duplicate(true),true) check(panel.content_revision == revision,"An unchanged controller page must not schedule another viewport render") check(panel.viewport.render_target_update_mode == SubViewport.UPDATE_ONCE,"The themed navigation panel must render on demand") await rendered() panel.viewport.get_texture().get_image().save_png("res://local_game_data/controller-navigation-theme.png") var player := {"system_status":[ {"key":"shields","id":0,"allocated":3,"max_power":4,"damaged":1,"ionized":0,"powerable":true}, {"key":"engines","id":1,"allocated":2,"max_power":5,"damaged":0,"ionized":0,"powerable":true}, {"key":"weapons","id":3,"allocated":3,"max_power":4,"damaged":0,"ionized":0,"powerable":true}, {"key":"oxygen","id":2,"allocated":1,"max_power":1,"damaged":0,"ionized":0,"powerable":true}, {"key":"mind","id":14,"allocated":0,"max_power":3,"damaged":0,"ionized":2,"powerable":true}, {"key":"doors","id":8,"allocated":0,"max_power":3,"damaged":0,"ionized":0,"powerable":false}], "reactor":{"available":7,"total":16,"used":9,"battery_available":0,"battery_total":0}} panel.set_power(player) check(panel.power_rows.size() == 5 and panel.selected_system().id == 0,"The power page must display installed powerable systems and preserve native system IDs") revision = panel.content_revision player.crew = [{"name":"Moving crew"}] panel.set_power(player) check(panel.content_revision == revision,"Unrelated crew movement must not redraw the system power page") panel.step_selection(-1) check(panel.selected_system().key == "mind","X must wrap to the previous installed powerable system") panel.step_selection(1) check(panel.selected_system().key == "shields","Y must wrap back to the next system") panel.select_system("weapons") check(panel.selected_system().allocated == 3 and panel.selected_system().max_power == 4,"Selected system power values must come from the native live schema") panel.select_system("missing") check(panel.selected_system().key == "weapons","A missing system must not replace the valid selection") panel.global_transform = Transform3D(Basis.from_euler(Vector3(-.5,.3,.1)).scaled(Vector3.ONE * .6),Vector3(.4,.9,-.7)) var rect: Rect2 = panel.power_card_rect(4) var point := pixel_world(panel,rect.get_center()) var hit: Dictionary = panel.ray_hit(point + panel.global_basis.z.normalized(),-panel.global_basis.z.normalized()) check(hit.get("system_key","") == "mind" and hit.position.distance_to(point) < .0001,"Rotated and scaled power cards must be pointable without native frame pixels or physics collisions") hit = panel.ray_hit(panel.to_global(Vector3(0,.345,1)),-panel.global_basis.z.normalized()) check(not hit.is_empty() and hit.system_key == "","Blank panel/header areas must block the world without changing selection") panel.visible = false check(panel.ray_hit(point + panel.global_basis.z.normalized(),-panel.global_basis.z.normalized()).is_empty(),"A hidden power page must not intercept the controller ray") panel.visible = true panel.global_transform = Transform3D.IDENTITY await rendered() var power_image: Image = panel.viewport.get_texture().get_image() power_image.save_png("res://local_game_data/controller-system-power-theme.png") # Changing power schedules exactly one real render and changes GPU pixels. var before: Color = power_image.get_pixel(276,217) player.system_status[0].allocated = 0 revision = panel.content_revision panel.set_power(player) check(panel.content_revision == revision + 1,"A live allocation change must request one power-page render") await rendered() check(panel.viewport.get_texture().get_image().get_pixel(276,217) != before,"Native power changes must update the visible GPU power bars") # Native updates may reorder rows; retain stable key selection. player.system_status.reverse() panel.set_power(player) check(panel.selected_system().key == "weapons","Native row reordering must preserve the selected system") # Actual native QA: a Zoltan in Oxygen gives allocated=0, bonus=1, # effective=1. Battery is separate from allocated in GetEffectivePower. var source_player := {"system_status":[ {"key":"oxygen","id":2,"allocated":0,"bonus_power":1,"battery_power":0,"effective_power":1,"max_power":1,"power_cap":1,"powerable":true}, {"key":"weapons","id":3,"allocated":1,"bonus_power":1,"battery_power":2,"effective_power":4,"max_power":5,"damaged":1,"power_cap":4,"powerable":true}, {"key":"engines","id":1,"allocated":1,"effective_power":1,"max_power":3,"power_cap":2,"ionized":true,"locked":true,"powerable":true}, {"key":"hacking","id":15,"allocated":1,"effective_power":1,"max_power":2,"power_cap":2,"hacked":2,"powerable":true}], "reactor":{"available":7,"total":16,"used":9,"battery_available":0,"battery_total":2}} panel.set_power(source_player) panel.select_system("oxygen") await rendered() var source_image: Image = panel.viewport.get_texture().get_image() source_image.save_png("res://local_game_data/controller-system-power-sources.png") check(source_image.get_pixel(269,213).to_html(false) == "fffa5a","A zero-reactor Oxygen system powered by a Zoltan must render a native yellow bonus pip") check(panel.selected_system().allocated == 0 and panel.selected_system().bonus_power == 1 and panel.selected_system().effective_power == 1,"Drawing effective power must preserve raw native selected-row power fields") check(panel.reactor.available == 7 and panel.reactor.used == 9,"Zoltan and battery display must not debit, recompute or double count native reactor availability") check(source_image.get_pixel(269,319).to_html(false) == "7fff74","Reactor-supplied system power must retain its green pip") check(source_image.get_pixel(294,319).to_html(false) == "7fff74" and source_image.get_pixel(282,318).to_html(false) == "e66e1e","Battery pips must have the native green inner pip and distinct orange outline") check(source_image.get_pixel(344,319).to_html(false) == "fffa5a","Bonus power must follow allocated and battery pips, without counting battery twice") check(source_image.get_pixel(369,319).to_html(false) == "ed6260","Damaged capacity must remain red after powered-source pips") check(source_image.get_pixel(719,213).to_html(false) == "85e7ed","Ion-locked allocated power must use the native cyan status color") check(source_image.get_pixel(757,212).to_html(false) == "85e7ed","Power capped cells must retain a cyan outline rather than appear available") check(source_image.get_pixel(719,319).to_html(false) == "cf46fd","Hacked allocated power must use the native purple status color") # The effective-power numerator is authoritative even when raw allocated is 0. # Compare its rendered numeric region against the otherwise identical 0/1 case. var powered_count: Image = source_image.get_region(Rect2i(537,206,50,15)) source_player.system_status[0].effective_power = 0 source_player.system_status[0].bonus_power = 0 panel.set_power(source_player) await rendered() check(powered_count.get_data() != panel.viewport.get_texture().get_image().get_region(Rect2i(537,206,50,15)).get_data(),"The visible power count must show native effective 1/1 instead of raw allocated 0/1") var storm_player := source_player.duplicate(true) storm_player.reactor = {"raw_available":4,"available":0,"usable_available":0,"usable_total":8,"total":8,"installed":12,"cap_loss":4,"storm_loss":4,"battery_available":2,"battery_total":2} panel.set_power(storm_player) var reactor_view: Dictionary = panel.reactor_display() check(reactor_view.free == 0 and reactor_view.total == 8 and reactor_view.installed == 12 and reactor_view.storm_loss == 4,"Storm-disabled reactor bars must not be shown as usable free power") check(reactor_view.battery_free == 2 and reactor_view.free == 0,"Battery availability must remain distinct from free reactor bars") await rendered() panel.viewport.get_texture().get_image().save_png("res://local_game_data/controller-system-power-storm.png") panel.set_power(source_player) var wheel: Node3D = load("res://scripts/shortcut_wheel.gd").new() root.add_child(wheel) var equipment_player := {"weapons":[{"slot":0,"name":"LASER_BURST_2","title":"Laser à impulsions II","kind":"laser","charge":1.0},{"slot":1,"name":"MISSILES_1","title":"Artemis","kind":"missile","missile_cost":1}],"drones":[{"slot":-1,"equipment_slot":0,"title":"Combat I","kind":"combat","x":4},{"slot":-1,"title":"Special actor","kind":"hacking"}]} var equipment: Array = wheel.equipment_entries(equipment_player,1) check(equipment.size() == 8 and equipment[0].label == "Laser à impulsions II" and equipment[1].detail == "1 MISSILE","Wheel slots must expose localized equipped names and actual ammo costs") check(equipment[4].label == "Combat I" and equipment[4].key == 53 and not equipment[2].enabled and not equipment[5].enabled,"Equipment slots must preserve native keys and omit special-drone actors/empty slots") var equipment_schema := equipment_player.duplicate(true) equipment_schema.weapons[1].ammo_cost = 2 equipment_schema.drone_equipment = [{"slot":0,"title":"Defense II","kind":"defense"}] var native_equipment: Array = wheel.equipment_entries(equipment_schema,1) check(native_equipment[1].detail == "2 MISSILES" and native_equipment[4].label == "Defense II","Authoritative ammo_cost and separate installed-drone metadata must take priority over actor fallbacks") var no_drones := equipment_player.duplicate(true) no_drones.drone_equipment = {} var empty_native_table: Array = wheel.equipment_entries(no_drones,0) check(empty_native_table[0].enabled and empty_native_table[0].label == "Laser à impulsions II" and empty_native_table[1].enabled,"A native empty Lua drone table must not suppress equipped weapons or fail the wheel") check(not empty_native_table[4].enabled and empty_native_table[4].label == "EMPTY","An explicit empty installed-drone table must not fall back to moving special actors") no_drones.weapons = {} no_drones.drone_equipment = null check(not wheel.equipment_entries(no_drones,0)[0].enabled,"Empty or temporarily unavailable native equipment must produce empty slots without a typed-array error") var native_path := "res://local_game_data/live_state.json" var recorded_wheel: Array = [] if FileAccess.file_exists(native_path): var native_state: Variant = JSON.parse_string(FileAccess.get_file_as_string(native_path)) if native_state is Dictionary and native_state.get("player", {}).get("weapons", []) is Array: var live_weapons: Array = native_state.player.weapons var live_wheel: Array = wheel.equipment_entries(native_state.player,int(native_state.get("drone_slots",0))) recorded_wheel = live_wheel for i in range(mini(4,live_weapons.size())): check(live_wheel[i].enabled and live_wheel[i].label == live_weapons[i].get("title",live_weapons[i].get("name","")),"The recorded native player equipment must appear in the wheel") wheel.set_entries(equipment,"WEAPONS\nDRONES") revision = wheel.content_revision equipment_player.weapons[0].charge = 8.5 equipment_player.drones[0].x = 15 wheel.set_entries(wheel.equipment_entries(equipment_player,1),"WEAPONS\nDRONES") check(wheel.content_revision == revision,"Changing charge/positions must not redraw unchanged wheel equipment labels") var long_name := "TrèsLongNomSansEspacePourUneArmeModifiée " + "With more translated words to clip" var bounded: Array = wheel.bounded_lines(long_name) check(bounded.size() <= 3 and str(bounded.back()).ends_with("..."),"Long localized equipment names must be clipped to bounded sector lines") for line in bounded: check(load("res://scripts/ftl_ui_theme.gd").text_size(str(line),23).x <= 136.01,"Every wheel name line must fit its measured native-font width") wheel.choose(Vector2(0,1)) wheel.visible = true await rendered() wheel.viewport.get_texture().get_image().save_png("res://local_game_data/controller-action-wheel-equipment.png") if not recorded_wheel.is_empty(): wheel.set_entries(recorded_wheel,"WEAPONS\nDRONES") await rendered() wheel.viewport.get_texture().get_image().save_png("res://local_game_data/controller-action-wheel-native.png") wheel.set_entries([{"label":"BURST LASER II","enabled":true},{"label":"ARTEMIS","enabled":true},{"label":"EMPTY","enabled":false},{"label":"EMPTY","enabled":false},{"label":"COMBAT DRONE","enabled":true},{"label":"BEAM DRONE","enabled":true}],"WEAPONS\nDRONES") wheel.choose(Vector2(0,1)) revision = wheel.content_revision wheel.choose(Vector2(0,.9)) check(wheel.selected == 0 and wheel.content_revision == revision,"A stable wheel sector must not rerender every joystick sample") wheel.choose(Vector2(1,0)) check(wheel.selected == 2,"The themed wheel must retain clockwise 1-8 native slot ordering") wheel.choose(Vector2.ZERO) check(wheel.selected == -1,"The wheel center must continue to cancel") wheel.choose(Vector2(0,1)) wheel.visible = true await rendered() wheel.viewport.get_texture().get_image().save_png("res://local_game_data/controller-action-wheel-theme.png") var help: Node3D = load("res://scripts/controller_help.gd").new() root.add_child(help) await rendered() help.viewport.get_texture().get_image().save_png("res://local_game_data/controller-help-theme.png") check(help.canvas.font == load("res://scripts/ftl_ui_assets.gd").font("body"),"Help callouts must use the actual vanilla UI font") var keyboard: Node3D = load("res://scripts/rename_keyboard.gd").new() root.add_child(keyboard) keyboard.set_entry({"active":true,"id":"crew1","kind":"CREW","text":"Elisabeth","cursor":9}) check(keyboard.title.get_theme_font("font") == load("res://scripts/ftl_ui_assets.gd").font("header") and keyboard.value_label.get_theme_font("font") == load("res://scripts/ftl_ui_assets.gd").font("body"),"Rename keyboard and name entry must use actual native fonts") var q_point := Vector3((74.0 / 1000.0 - .5) * .86,(.5 - 188.0 / 454.0) * .39,0) var q_hit: Dictionary = keyboard.ray_hit(q_point + Vector3(0,0,1),Vector3(0,0,-1)) check(q_hit.get("text","") == "Q" and keyboard.activate(q_hit).get("op","") == "insert","Restyling the keyboard must preserve QWERTY key picking and scoped text operations") await rendered() keyboard.viewport.get_texture().get_image().save_png("res://local_game_data/controller-keyboard-theme.png") var hull_bar: Node3D = load("res://scripts/enemy_hull_bar.gd").new() root.add_child(hull_bar) hull_bar.build() hull_bar.set_state({"hull":30,"hull_max":30,"name":"REBEL FIGHTER"}) await rendered() var full_hull: Image = hull_bar.viewport.get_texture().get_image() check(full_hull.get_pixel(690,74).g > .9 and full_hull.get_pixel(690,74).r < .6,"Full enemy hull must use the actual vanilla segmented-mask fill") hull_bar.set_state({"hull":18,"hull_max":30,"name":"REBEL FIGHTER"}) await rendered() var partial_hull: Image = hull_bar.viewport.get_texture().get_image() partial_hull.save_png("res://local_game_data/controller-enemy-hull-theme.png") check(partial_hull.get_pixel(690,74).g < .5 and partial_hull.get_pixel(120,74).g > .9,"Native enemy hull changes must shorten the visible health fill and preserve remaining hull cells") check(partial_hull.get_pixel(400,8).a == 0,"The enemy hull display must retain transparent space around the native contour without a generic banner") check(load("res://scripts/ftl_ui_assets.gd").texture("img/statusUI/top_hull.png") != null,"The hull contour must come from owner-local vanilla art") hull_bar.set_state({}) check(not hull_bar.visible,"Enemy hull display must disappear when native enemy state is absent") await _verify_world_font_render() panel.queue_free() wheel.queue_free() help.queue_free() keyboard.queue_free() hull_bar.queue_free() await process_frame print("CONTROLLER_UI_TESTS ","PASS" if failures == 0 else "FAIL %d" % failures) quit(0 if failures == 0 else 1) func _verify_bindings() -> void: var path := "res://openxr_action_map.tres" for argument in OS.get_cmdline_user_args(): if argument.begins_with("--action-map="): path = argument.trim_prefix("--action-map=") var map: OpenXRActionMap = load(path) check(map != null,"The controller action map must parse as an OpenXRActionMap") if map == null: return var profile: OpenXRInteractionProfile = map.find_interaction_profile("/interaction_profiles/valve/frame_controller_valve") check(profile != null,"The explicit Steam Frame interaction profile must exist") if profile == null: return var paths: Dictionary = {} for binding in profile.get_bindings(): var name: String = binding.get_action().resource_name if not paths.has(name): paths[name] = [] paths[name].append(binding.get_binding_path()) var required := { "tactical_button":"/user/hand/left/input/dpad_down/click", "power_remove":"/user/hand/left/input/bumper/click", "power_previous":"/user/hand/right/input/x/click", "power_next":"/user/hand/right/input/y/click", "page_left":"/user/hand/left/input/dpad_left/click", "page_right":"/user/hand/left/input/dpad_right/click", "wheel_button":"/user/hand/right/input/bumper/click", } var actions: Dictionary = {} for action in map.get_action_set(0).get_actions(): actions[action.resource_name] = action for name in required: check(paths.get(name,[]) == [required[name]],"Frame %s must use the actual observed hardware button path" % name) check(actions.has(name) and actions[name].action_type == OpenXRAction.OPENXR_ACTION_BOOL,"Frame %s must be a boolean action in the active action set" % name) if not actions.has(name): continue var hand: String = "/user/hand/right" if name in ["power_previous","power_next","wheel_button"] else "/user/hand/left" check(hand in actions[name].toplevel_paths,"Frame %s must include its actual hardware hand" % name) check(paths.get("ax_button",[]) == ["/user/hand/right/input/a/click"],"Frame Dpad Down must not alias A/X and trigger both tactical and pause") check("/user/hand/left/input/view/click" in paths.get("menu_button",[]),"Left View must retain pause") check("/user/hand/left/input/dpad_up/click" in paths.get("by_button",[]),"Dpad Up must retain shortcuts") # The installed vendor Frame profile declares X/Y on the RIGHT controller. # Reject accidental Touch-controller assumptions (left X/Y) in this profile. for bound_paths in paths.values(): check(not "/user/hand/left/input/x/click" in bound_paths and not "/user/hand/left/input/y/click" in bound_paths,"Frame X/Y must not be bound to nonexistent left-hand buttons") func _verify_native_fonts() -> void: var assets = load("res://scripts/ftl_ui_assets.gd") for role in ["body","header","bold"]: var native: Font = assets.font(role) check(native is FontFile and native != ThemeDB.fallback_font,"The %s UI font must load the actual owner-local FTL atlas without editor import" % role) if native is FontFile and native != ThemeDB.fallback_font: check(native.has_char(65) and native.has_char(233),"Native UI font must retain Western glyphs and accented crew names") check(native.fixed_size > 0 and native.get_string_size("FTL",HORIZONTAL_ALIGNMENT_LEFT,-1,32).x > 0,"Native bitmap font must expose its original metrics and scalable advances") check(assets.font(role) == native,"Native UI font instances must be cached rather than decoded on every label") check(assets.load_font("res://local_game_data/fonts/missing.font") == null,"Missing local fonts must fall back without attempting system installation") # Compare the runtime A glyph against the original SIL byte metrics/atlas. var original := FileAccess.get_file_as_bytes(assets.data_path().path_join("fonts/JustinFont10.font")) var body: FontFile = assets.font("body") var info: int = assets._be32(original,8) var height := int(original[5]) for i in range(assets._be16(original,12)): var offset: int = info + i * 16 if assets._be32(original,offset) != 65: continue var expected_advance: float = original[offset + 8] + assets._signed(assets._be16(original,offset + 12),16) / 256.0 + assets._signed(assets._be16(original,offset + 14),16) / 256.0 check(is_equal_approx(body.get_glyph_advance(0,height,65).x,expected_advance),"Native font advances must preserve original fractional pre/post bearings") check(body.get_glyph_offset(0,Vector2i(height,0),65).y + 2 == -assets._signed(original[offset + 10],8),"Native font baseline must retain original glyph ascent after transparent atlas padding") var tex: int = assets._be32(original,16) var pixel_offset: int = assets._be32(original,tex + 16) var width: int = assets._be16(original,tex + 8) var atlas := body.get_texture_image(0,Vector2i(height,0),0) var original_x: int = assets._be16(original,offset + 4) var original_y: int = assets._be16(original,offset + 6) var uv: Rect2 = body.get_glyph_uv_rect(0,Vector2i(height,0),65) var gx := int(uv.position.x) + 2 var gy := int(uv.position.y) + 2 for y in range(original[offset + 9]): for x in range(original[offset + 8]): check(roundi(atlas.get_pixel(gx + x,gy + y).a * 255) == original[tex + pixel_offset + (original_y + y) * width + original_x + x],"Native font atlas pixels must match the owned source, not a replacement alphabet") break var malformed := FileAccess.open("res://local_game_data/fonts/test-invalid.font",FileAccess.WRITE) malformed.store_buffer(original.slice(0,24)) malformed.close() check(assets.load_font("res://local_game_data/fonts/test-invalid.font") == null,"Truncated native atlas/index data must fail cleanly") DirAccess.remove_absolute(ProjectSettings.globalize_path("res://local_game_data/fonts/test-invalid.font")) func _verify_world_font_render() -> void: var assets = load("res://scripts/ftl_ui_assets.gd") var preview := SubViewport.new() preview.size = Vector2i(640,240) preview.transparent_bg = true preview.own_world_3d = true preview.render_target_update_mode = SubViewport.UPDATE_ONCE root.add_child(preview) var camera := Camera3D.new() camera.position.z = 2 camera.projection = Camera3D.PROJECTION_ORTHOGONAL camera.size = 2.4 camera.current = true preview.add_child(camera) var labels: Array[Label3D] = [] for i in range(2): var label := Label3D.new() label.font = assets.font("header" if i == 0 else "body") label.text = "FTL HULL" if i == 0 else "Elisabeth / SHIELDS" label.font_size = 48 label.pixel_size = .012 label.outline_size = 0 label.texture_filter = BaseMaterial3D.TEXTURE_FILTER_NEAREST label.position.y = .25 if i == 0 else -.25 preview.add_child(label) labels.append(label) await rendered() var image := preview.get_texture().get_image() image.save_png("res://local_game_data/controller-world-native-fonts.png") for half in range(2): var opaque := 0 for y in range(half * 120,(half + 1) * 120): for x in range(640): if image.get_pixel(x,y).a > .5: opaque += 1 check(opaque > 50,"Native %s FontFile must render visible Label3D glyphs with zero outline" % ("header" if half == 0 else "body")) # A raw tight atlas rendered broad letters while dropping every 2px glyph # I/i/l in Label3D. Isolate those letters so the broad glyphs cannot mask it. labels[0].visible = false labels[1].text = "Iil" preview.render_target_update_mode = SubViewport.UPDATE_ONCE await rendered() image = preview.get_texture().get_image() image.save_png("res://local_game_data/controller-world-narrow-glyphs.png") var narrow_pixels := 0 for y in range(240): for x in range(640): if image.get_pixel(x,y).a > .5: narrow_pixels += 1 check(narrow_pixels > 50,"Original narrow I/i/l glyphs must remain visible in nearest-filtered Label3D via padded UV quads") preview.queue_free()