First publishable version of the mod
This commit is contained in:
ElHombreAlex committed 2026-10-03 12:59:33 +02:00
commit 35cdf71054
112 files changed
+18125

No files matched your search

+76
View File
@@ -0,0 +1,76 @@
extends SceneTree
const BEAMS := 12
const SAMPLES := 120
func _initialize() -> void:
call_deferred("_run")
func _run() -> void:
Engine.max_fps = 90
var stage := Node3D.new()
root.add_child(stage)
var camera := Camera3D.new()
stage.add_child(camera)
camera.position = Vector3(0.0, 1.4, 4.0)
camera.look_at(Vector3.ZERO)
camera.current = true
var effects: Node3D = load("res://scripts/combat_effects.gd").new()
stage.add_child(effects)
var results := {}
for mode in ["mutable_mesh", "unit_transform"]:
var phase := Node3D.new()
stage.add_child(phase)
var segments: Array[MeshInstance3D] = []
var changed := {"count": 0}
var watched := {}
for i in range(BEAMS):
var beam: MeshInstance3D = effects._projectile_visual("beam", Color.ORANGE)
phase.add_child(beam)
for child in beam.get_children():
var segment := child as MeshInstance3D
if mode == "mutable_mesh":
segment.mesh = segment.mesh.duplicate()
segments.append(segment)
if not watched.has(segment.mesh.get_instance_id()):
watched[segment.mesh.get_instance_id()] = true
segment.mesh.changed.connect(func(): changed["count"] += 1)
var total_us := 0
for frame in range(SAMPLES + 20):
if frame == 20:
changed["count"] = 0
var start_us := Time.get_ticks_usec()
for i in range(segments.size()):
var start := Vector3(-1.5, float(i / 2) * 0.025 - 0.2, 0.0)
var end := Vector3(1.5, sin(frame * 0.027 + i * 0.01) * 0.65, cos(frame * 0.023) * 0.28)
var radius := 0.004 if i % 2 == 0 else 0.012
if mode == "mutable_mesh":
_mutable_segment(segments[i], start, end, radius)
else:
effects._segment(segments[i], start, end, radius)
if frame >= 20:
total_us += Time.get_ticks_usec() - start_us
await process_frame
await RenderingServer.frame_post_draw
results[mode] = {"cpu_update_us_per_frame": float(total_us) / SAMPLES,
"mesh_resource_changes": changed["count"], "mesh_resources": watched.size()}
phase.queue_free()
await process_frame
print("BEAM_BENCHMARK beams=", BEAMS, " samples=", SAMPLES, " ", JSON.stringify(results))
quit()
func _mutable_segment(node: MeshInstance3D, start: Vector3, end: Vector3, radius: float) -> void:
var direction := end - start
var length := direction.length()
var axis := direction / length
var reference := Vector3.RIGHT if absf(axis.dot(Vector3.UP)) > 0.98 else Vector3.UP
var side := reference.cross(axis).normalized()
node.global_basis = Basis(side, axis, side.cross(axis))
node.global_position = (start + end) * 0.5
var mesh := node.mesh as CylinderMesh
mesh.height = length
mesh.top_radius = radius
mesh.bottom_radius = radius
+37
View File
@@ -0,0 +1,37 @@
extends SceneTree
func _initialize() -> void:
call_deferred("_capture")
func _capture() -> void:
var stage := Node3D.new()
root.add_child(stage)
var world := WorldEnvironment.new()
var environment := Environment.new()
environment.background_mode = Environment.BG_COLOR
environment.background_color = Color(0.002, 0.003, 0.008)
environment.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR
environment.ambient_light_color = Color(0.5, 0.6, 0.75)
environment.ambient_light_energy = 0.7
world.environment = environment
stage.add_child(world)
var light := DirectionalLight3D.new()
light.rotation_degrees = Vector3(-35.0, 20.0, 0.0)
stage.add_child(light)
var camera := Camera3D.new()
stage.add_child(camera)
camera.position = Vector3(0.0, 1.8, 2.0)
camera.look_at(Vector3(0.0, 1.8, -1.0))
camera.current = true
var space: Node3D = load("res://scripts/space_environment.gd").new()
stage.add_child(space)
space.build()
for kind in ["clear", "asteroid", "sun", "storm", "nebula", "pulsar"]:
space.apply_state({"hazard": kind})
await create_timer(0.3).timeout
await RenderingServer.frame_post_draw
root.get_texture().get_image().save_png(ProjectSettings.globalize_path("res://local_game_data/environment_%s.png" % kind))
print("ENVIRONMENT_CAPTURE PASS")
quit()
+1
View File
@@ -0,0 +1 @@
uid://uxghhh678b68
+36
View File
@@ -0,0 +1,36 @@
extends SceneTree
func _initialize() -> void:
call_deferred("run")
func run() -> void:
var scene: Node3D = load("res://main.tscn").instantiate()
root.add_child(scene)
await process_frame
scene.set_process(false)
scene.xr_active = true
scene.left_hand.transform = scene.camera.transform
scene.nav_panel.transform = Transform3D(Basis.IDENTITY,Vector3(-0.3,-0.05,-1.05))
scene.nav_panel.scale = Vector3.ONE * 0.6
scene.nav_panel.visible = true
scene.right_hand.transform = scene.camera.transform * Transform3D(Basis.IDENTITY,Vector3(0.25,-0.22,-0.3))
# A simulated tracked ray uses the same drawing and picking path as XR.
var tracker := XRControllerTracker.new()
tracker.type = XRServer.TRACKER_CONTROLLER
tracker.name = "right_hand"
tracker.set_pose("aim",scene.right_hand.transform,Vector3.ZERO,Vector3.ZERO,XRPose.XR_TRACKING_CONFIDENCE_HIGH)
XRServer.add_tracker(tracker)
for page in ["navigation","shortcuts","help","wheel"]:
scene.wheel.visible = page == "wheel"
if page == "wheel":
scene._refresh_wheel()
scene.wheel.global_transform = scene.camera.global_transform
scene.wheel.global_position += -scene.camera.global_basis.z * 0.85 + scene.camera.global_basis.x * 0.30
scene.wheel.choose(Vector2(0,1))
scene.nav_page = page if page != "wheel" else "navigation"
scene._refresh_navigation()
scene.right_hand.look_at(scene.nav_panel.global_position,Vector3.UP)
scene._update_pointer()
for i in range(8): await process_frame
await RenderingServer.frame_post_draw
root.get_texture().get_image().save_png("res://local_game_data/input-"+page+".png")
XRServer.remove_tracker(tracker)
quit()
+1
View File
@@ -0,0 +1 @@
uid://bm17rm3w3bb0g
+13
View File
@@ -0,0 +1,13 @@
extends SceneTree
func _initialize() -> void:
call_deferred("capture")
func capture() -> void:
var scene: Node3D = load("res://main.tscn").instantiate()
root.add_child(scene)
await create_timer(2.0).timeout
await RenderingServer.frame_post_draw
root.get_texture().get_image().save_png(ProjectSettings.globalize_path("res://local_game_data/live_preview.png"))
print("LIVE_RENDER ready=", scene.bridge_connected, " crew=", scene.player_ship.crew_nodes.size())
quit()
+1
View File
@@ -0,0 +1 @@
uid://w2lkox2jrhi0
+108
View File
@@ -0,0 +1,108 @@
extends SceneTree
const CrewModel = preload("res://scripts/voxel_crew.gd")
const WeaponModel = preload("res://scripts/voxel_weapon.gd")
const ShipModel = preload("res://scripts/ship_model.gd")
const DroneModel = preload("res://scripts/voxel_drone.gd")
const UiAssets = preload("res://scripts/ftl_ui_assets.gd")
func _initialize() -> void:
call_deferred("_run")
func _run() -> void:
root.size = Vector2i(1600, 900)
var weapons_only := OS.get_cmdline_user_args().has("--weapons")
var drones_only := OS.get_cmdline_user_args().has("--drones")
var crew_only := OS.get_cmdline_user_args().has("--crew")
var world := Node3D.new()
root.add_child(world)
var environment := WorldEnvironment.new()
var settings := Environment.new()
settings.background_mode = Environment.BG_COLOR
settings.background_color = Color("111b25")
settings.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR
settings.ambient_light_color = Color("d4e7f2")
settings.ambient_light_energy = 0.65
environment.environment = settings
world.add_child(environment)
var light := DirectionalLight3D.new()
light.rotation_degrees = Vector3(-40, -30, 0)
light.light_energy = 1.6
world.add_child(light)
var camera := Camera3D.new()
camera.projection = Camera3D.PROJECTION_ORTHOGONAL
camera.size = 3.0 if drones_only else 2.5 if crew_only else 1.8 if weapons_only else 2.1
camera.position = Vector3(0.6, 3.4 if drones_only or crew_only else 2.2, 4.3)
world.add_child(camera)
camera.look_at(Vector3(0, 0.2, 0.2))
if drones_only:
var kinds := ["combat", "beam", "defense", "hacking", "shield", "boarding", "anti_drone", "repair", "battle", "ion_boarder"]
for i in range(kinds.size()):
var drone := DroneModel.new()
drone.position = Vector3((i % 5 - 2) * 0.75, 0.22, -0.95 if i < 5 else 0.95)
drone.scale = Vector3.ONE * 2.8
world.add_child(drone)
var exterior: bool = kinds[i] not in ["repair", "battle", "ion_boarder"]
drone.build({"kind": kinds[i], "species": kinds[i], "name": str(kinds[i]).to_upper() + "_1", "is_space": exterior,
"deployed": true, "powered": true, "repairing": kinds[i] == "repair", "fighting": kinds[i] == "battle"})
# Gallery poses are presentation fixtures, not synthetic game state.
drone.rotation.y = -0.55 if exterior else PI + 0.2
drone.animate(0.3)
_label(world, str(kinds[i]).replace("_", " ").to_upper(), drone.position + Vector3(0, -0.16, 0.27))
for i in range(CrewModel.SPECIES.size()):
if weapons_only or drones_only:
break
var model := CrewModel.new()
model.position = Vector3((i % 4 - 1.5) * (0.78 if crew_only else 0.63), 0.1,
(-0.95 if i < 4 else 0.95) if crew_only else (-0.65 if i < 4 else 0.4))
model.scale = Vector3.ONE * 3.0
world.add_child(model)
model.build(CrewModel.SPECIES[i])
model.facing = PI + 0.3
var states := ["work", "repair", "run", "fight"]
var state: String = states[i % 4]
model.set_state({"working": state == "work", "repairing": state == "repair", "running": state == "run", "fighting": state == "fight"})
model.animate(0.3)
model.face_viewer(camera.position - model.position, 1.0)
_label(world, str(CrewModel.SPECIES[i]).to_upper() + "\n" + state, model.position + Vector3(0, -0.06, 0.20))
for i in range(6):
if drones_only or crew_only: break
var weapon := WeaponModel.new()
weapon.position = Vector3((i % 3 - 1) * 0.88, 0.12, -0.52 if i < 3 else 0.70) if weapons_only else Vector3((i - 2.5) * 0.48, 0.04, 1.35)
weapon.scale = Vector3.ONE * (2.8 if weapons_only else 1.8)
world.add_child(weapon)
var kinds := ["laser", "ion", "beam", "missile", "bomb", "flak"]
var fraction := float(i) / 5.0 if weapons_only else 1.0
weapon.build({"kind": kinds[i], "powered": true, "charge_fraction": fraction, "charge_max": 4 if kinds[i] == "laser" else 1, "charge_level": 2 if kinds[i] == "laser" else 0})
var label := Label3D.new()
label.font = UiAssets.font("body")
label.outline_size = 0
label.texture_filter = BaseMaterial3D.TEXTURE_FILTER_NEAREST
label.text = str(kinds[i]).to_upper() + (" • %d%% CHARGED" % roundi(fraction * 100) if weapons_only else "")
label.font_size = 36
label.pixel_size = 0.0025
label.position = weapon.position + Vector3(0, -0.02, 0.30 if weapons_only else 0.13)
label.billboard = BaseMaterial3D.BILLBOARD_ENABLED
world.add_child(label)
for i in range(12):
await process_frame
await RenderingServer.frame_post_draw
var path := ProjectSettings.globalize_path("res://local_game_data/drone_gallery.png" if drones_only else "res://local_game_data/crew_gallery.png" if crew_only else "res://local_game_data/weapon_gallery.png" if weapons_only else "res://local_game_data/model_gallery.png")
root.get_texture().get_image().save_png(path)
print("MODEL_CAPTURE ", path)
quit()
func _label(world: Node3D, text: String, location: Vector3) -> void:
var label := Label3D.new()
label.text = text
label.font = UiAssets.font("body")
label.outline_size = 0
label.font_size = 38
label.pixel_size = 0.0025
label.position = location
label.texture_filter = BaseMaterial3D.TEXTURE_FILTER_NEAREST
label.billboard = BaseMaterial3D.BILLBOARD_ENABLED
world.add_child(label)
+140
View File
@@ -0,0 +1,140 @@
extends SceneTree
const ShipModel = preload("res://scripts/ship_model.gd")
func _initialize() -> void:
call_deferred("_run")
func _run() -> void:
root.size = Vector2i(1280, 720)
var args := OS.get_cmdline_user_args()
var state_path := "res://local_game_data/live_state.json"
var output_path := ""
for argument in args:
if argument.begins_with("--state="):
state_path = argument.trim_prefix("--state=")
elif argument.begins_with("--output="):
output_path = argument.trim_prefix("--output=")
var data: Dictionary = JSON.parse_string(FileAccess.get_file_as_string(state_path))
var side := "enemy" if args.has("--enemy") else "player"
if not data.has(side):
push_error("No native %s ship in the current snapshot" % side)
quit(1)
return
var native_ship: Dictionary = data[side]
var ship := ShipModel.new()
root.add_child(ship)
ship.build(str(native_ship.get("asset_key", native_ship.layout)), side == "enemy")
ship.inspect_rooms = true
ship.apply_live(native_ship)
ship.simulation_paused = true
var environment := WorldEnvironment.new()
var settings := Environment.new()
settings.background_mode = Environment.BG_COLOR
settings.background_color = Color("101822")
settings.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR
settings.ambient_light_color = Color("d4e7f2")
settings.ambient_light_energy = 0.45
environment.environment = settings
root.add_child(environment)
var light := DirectionalLight3D.new()
light.rotation_degrees = Vector3(-35, -25, 0)
light.light_energy = 1.5
root.add_child(light)
var camera := Camera3D.new()
camera.projection = Camera3D.PROJECTION_ORTHOGONAL
camera.size = maxf(2.0, maxf(ship.shield_radii.x, ship.shield_radii.z) * 2.35)
var view_center := Vector3(ship.shield_center.x, 0, ship.shield_center.z)
camera.position = view_center + Vector3(1.2, 2.3, 2.1)
root.add_child(camera)
camera.look_at(view_center)
ship.set_viewer_position(camera.global_position)
for i in range(12):
await process_frame
await RenderingServer.frame_post_draw
var path := ProjectSettings.globalize_path("res://local_game_data/native_models%s_preview.png" % ("_enemy" if side == "enemy" else ""))
if not output_path.is_empty():
path = ProjectSettings.globalize_path(output_path)
root.get_texture().get_image().save_png(path)
print("NATIVE_MODEL_CAPTURE ", side, " ", path, " crew=", ship.crew_nodes.size(), " doors=", ship.door_nodes.size(), " drones=", ship.drone_nodes.size(), " hull_voxels=", ship.hull_voxel_count)
var weapon_charge_state: Array = []
for slot in range(mini(ship.weapon_nodes.size(), native_ship.get("weapons", []).size())):
var weapon: Node3D = ship.weapon_nodes[slot]
weapon_charge_state.append({"slot": slot, "name": weapon.weapon_name, "powered": weapon.powered,
"charge_fraction": weapon.charge, "stored_shots": weapon.charge_level, "stored_max": weapon.charge_max})
var door_tiers: Dictionary = {}
for door in ship.door_nodes.values():
var tier: int = door.get_meta("blast_tier", 0)
door_tiers[tier] = int(door_tiers.get(tier, 0)) + 1
print("NATIVE_VISUAL_STATE weapons=", weapon_charge_state, " fire_tiles=", ship.room_hazards.fire_points.size(),
" breach_tiles=", ship.room_hazards.breach_points.size(), " effective_door_tiers=", door_tiers)
var crew_facing: Array = []
for miniature in ship.crew_nodes.values():
var model: Node3D = miniature.get_node("Model")
if model.has_method("face_viewer"):
var direction: Vector3 = ship.to_local(camera.global_position) - miniature.position
crew_facing.append({"id": miniature.get_meta("crew_id"), "state": model.animation_state,
"visible": miniature.visible,
"yaw_error": absf(wrapf(model.rotation.y - atan2(-direction.x, -direction.z), -PI, PI)),
"body_pitch": model.rotation.x, "body_roll": model.rotation.z})
elif model.get("family") != null:
crew_facing.append({"id": miniature.get_meta("crew_id"), "drone_family": model.family, "visible": miniature.visible})
print("NATIVE_CREW_FACING ", crew_facing)
var drone_models: Array = []
for native_drone in native_ship.get("drones", []):
var drone_id := str(native_drone.get("id", ""))
if ship.drone_nodes.has(drone_id):
var model: Node3D = ship.drone_nodes[drone_id]
drone_models.append({"id": drone_id, "name": model.drone_name, "family": model.family,
"position_error": model.position.distance_to(ship.pixel_point(native_drone, 0.3)),
"angle_error": absf(wrapf(model.rotation.y + deg_to_rad(float(native_drone.get("angle", 0))), -PI, PI))})
print("NATIVE_DRONE_MODELS ", drone_models)
var outside_footprint := 0
var outside_volume := 0
var outside_native_footprint := 0
var max_native_radial := 0.0
var shape: Dictionary = native_ship.get("shield_shape", {})
var native_radii := Vector2(float(shape.get("a", 0)) / 35.0 * ship.TILE, float(shape.get("b", 0)) / 35.0 * ship.TILE)
var hull: MultiMeshInstance3D = ship.get_node_or_null("ExtrudedHull")
if hull != null:
for i in range(hull.multimesh.instance_count):
var transform := hull.multimesh.get_instance_transform(i)
if native_radii.x > 0 and native_radii.y > 0:
var delta := transform.origin - ship.shield_center
var radial := Vector2(delta.x / native_radii.x, delta.z / native_radii.y).length()
max_native_radial = maxf(max_native_radial, radial)
outside_native_footprint += 1 if radial > 1.0 else 0
var local := (transform.origin - ship.shield_center) / ship.shield_radii
if local.x * local.x + local.z * local.z > 1.0:
outside_footprint += 1
local.y -= transform.basis.y.length() * 0.5 / ship.shield_radii.y
if local.length_squared() > 1.0:
outside_volume += 1
var max_room_alignment_px := 0.0
var max_mount_alignment_px := 0.0
var native_weapons: Array = native_ship.get("weapons", [])
var source_mounts: Array = ship.layout_data.get("weapon_mounts", [])
for slot in range(mini(native_weapons.size(), source_mounts.size())):
var native_mount: Dictionary = native_weapons[slot].get("mount", {})
if not native_mount.is_empty():
var source_mount: Dictionary = source_mounts[slot]
max_mount_alignment_px = maxf(max_mount_alignment_px, Vector2(float(native_mount.x) - float(source_mount.x), float(native_mount.y) - float(source_mount.y)).length())
var outside_corners := 0
var max_corner_radius := 0.0
for point in ship.shield_fit_points:
var radius: float = ((point - ship.shield_center) / ship.shield_radii).length()
max_corner_radius = maxf(max_corner_radius, radius)
outside_corners += 1 if radius > 1.001 else 0
for room in native_ship.get("rooms", []):
if ship.room_nodes.has(int(room["id"])):
var native_center: Vector3 = ship.pixel_point(room.get("center", {}), 0.065)
var error: Vector3 = native_center - ship.room_nodes[int(room["id"])].position
max_room_alignment_px = maxf(max_room_alignment_px, Vector2(error.x, error.z).length() * 35.0 / ship.TILE)
print("SHIELD_MODEL_GEOMETRY native=", shape, " local_center=", ship.shield_center, " radii=", ship.shield_radii, " padding=", ship.shield_fit_padding,
" native_outside_footprint=", outside_native_footprint, " native_radial_max=", max_native_radial, " outside_footprint=", outside_footprint,
" outside_keel=", outside_volume, " outside_hull_wall_corners=", outside_corners, " corner_radial_max=", max_corner_radius,
" room_alignment_max_px=", max_room_alignment_px, " weapon_mount_alignment_max_px=", max_mount_alignment_px,
" texture=", ship.layout_data.get("image_source_path", ""), " image_rect=", ship.layout_data.get("image_rect", {}))
quit()
+94
View File
@@ -0,0 +1,94 @@
extends SceneTree
const ShipModel = preload("res://scripts/ship_model.gd")
const HullBar = preload("res://scripts/enemy_hull_bar.gd")
const Hazards = preload("res://scripts/room_hazards.gd")
const Voxels = preload("res://scripts/voxel_mesh.gd")
const UiAssets = preload("res://scripts/ftl_ui_assets.gd")
func _initialize() -> void:
call_deferred("_run")
func _run() -> void:
# Visual fixture only; no bridge, input, game state, or save writes.
root.size = Vector2i(1400, 900)
var ship := ShipModel.new()
root.add_child(ship)
var hazards := Hazards.new()
ship.add_child(hazards)
hazards.build()
hazards.set_live(ship, {"rooms": [{"visible": true, "fire_tiles": [{"x": -90, "y": 115}], "breach_tiles": [{"x": 90, "y": 115}]}]})
hazards.animate(0.42)
for i in range(4):
var door_center := Vector3((i - 1.5) * 0.49, 0.22, -0.02)
var frame := Voxels.make([Voxels.block(Vector3(-0.077, 0, 0), Vector3(0.025, 0.11, 0.034), Color("516b77")),
Voxels.block(Vector3(0.077, 0, 0), Vector3(0.025, 0.11, 0.034), Color("516b77")),
Voxels.block(Vector3(0, 0.06, 0), Vector3(0.179, 0.01, 0.034), Color("516b77"))])
frame.position = door_center
frame.scale = Vector3.ONE * 2.0
root.add_child(frame)
for side in [-1, 1]:
var leaf: MeshInstance3D = ship._make_door_leaf(i)
leaf.position = door_center + Vector3(side * 0.031 * 2.0, 0, 0)
leaf.scale = Vector3.ONE * 2.0
root.add_child(leaf)
_label(["STANDARD", "BLAST", "IMPROVED", "CREW BOOST"][i], Vector3((i - 1.5) * 0.49, 0.12, 0.10))
var floor := MeshInstance3D.new()
var box := BoxMesh.new()
box.size = Vector3(1.6, 0.025, 0.39)
floor.mesh = box
floor.position = Vector3(0, 0.065, 0.59)
var material := StandardMaterial3D.new()
material.albedo_color = Color("66767c")
floor.material_override = material
root.add_child(floor)
_label("FIRE", Vector3(-0.46, 0.065, 0.80))
_label("HULL BREACH", Vector3(0.46, 0.065, 0.80))
var environment := WorldEnvironment.new()
var settings := Environment.new()
settings.background_mode = Environment.BG_COLOR
settings.background_color = Color("101923")
settings.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR
settings.ambient_light_color = Color("d4e7f2")
settings.ambient_light_energy = 0.45
environment.environment = settings
root.add_child(environment)
var light := DirectionalLight3D.new()
light.rotation_degrees = Vector3(-40, -25, 0)
light.light_energy = 1.6
root.add_child(light)
var camera := Camera3D.new()
camera.projection = Camera3D.PROJECTION_ORTHOGONAL
camera.size = 1.32
camera.position = Vector3(0.55, 1.8, 2.5)
root.add_child(camera)
camera.look_at(Vector3(0, 0.27, 0.33))
var life := HullBar.new()
root.add_child(life)
life.build()
life.set_state({"hull": 12, "hull_max": 20})
life.position = Vector3(0, 0.56, -0.15)
life.face_viewer(camera.position)
for i in range(12):
await process_frame
await RenderingServer.frame_post_draw
var path := ProjectSettings.globalize_path("res://local_game_data/ship_details_fixture.png")
root.get_texture().get_image().save_png(path)
print("SHIP_DETAIL_FIXTURE ", path, " fire_tiles=", hazards.fire_points.size(), " breach_tiles=", hazards.breach_points.size(), " door_tiers=0,1,2,3")
quit()
func _label(text: String, location: Vector3) -> void:
var label := Label3D.new()
label.text = text
label.font = UiAssets.font("body")
label.outline_size = 0
label.position = location
label.font_size = 38
label.texture_filter = BaseMaterial3D.TEXTURE_FILTER_NEAREST
label.pixel_size = 0.0016
label.billboard = BaseMaterial3D.BILLBOARD_ENABLED
label.no_depth_test = true
root.add_child(label)
+52
View File
@@ -0,0 +1,52 @@
extends SceneTree
# Explicit Steam Frame bindings; Godot's generic/Touch bindings remain fallbacks.
func _initialize() -> void:
var map := OpenXRActionMap.new()
map.create_default_action_sets()
var actions := {}
for action in map.get_action_set(0).get_actions():
actions[action.resource_name] = action
var custom := {"page_left":"left","page_right":"left","tactical_button":"left",
"power_remove":"left","power_previous":"right","power_next":"right","wheel_button":"right"}
for name in custom:
var action := OpenXRAction.new()
action.resource_name = name
action.localized_name = str(name).replace("_"," ").capitalize()
action.action_type = OpenXRAction.OPENXR_ACTION_BOOL
action.toplevel_paths = PackedStringArray(["/user/hand/" + custom[name]])
map.get_action_set(0).add_action(action)
actions[name] = action
var profile := OpenXRInteractionProfile.new()
profile.interaction_profile_path = "/interaction_profiles/valve/frame_controller_valve"
var bindings: Array[OpenXRIPBinding] = []
for hand in ["left", "right"]:
var prefix := "/user/hand/%s/" % hand
var paths := {"default_pose": "input/aim/pose", "aim_pose": "input/aim/pose",
"grip_pose": "input/grip/pose", "trigger": "input/trigger/value",
"trigger_click": "input/trigger/click", "grip": "input/squeeze/value",
"grip_click": "input/squeeze/click", "primary": "input/thumbstick",
"primary_click": "input/thumbstick/click", "haptic": "output/haptic",
"by_button": "input/dpad_up/click" if hand == "left" else "input/b/click",
"menu_button": "input/view/click" if hand == "left" else "input/menu/click"}
# The vendor profile in the installed SteamVR Frame driver declares X/Y
# on the right controller. The left controller has the Dpad and View.
if hand == "left":
paths.merge({"page_left":"input/dpad_left/click","page_right":"input/dpad_right/click",
"tactical_button":"input/dpad_down/click","power_remove":"input/bumper/click"})
else:
paths.merge({"ax_button":"input/a/click","power_previous":"input/x/click",
"power_next":"input/y/click","wheel_button":"input/bumper/click"})
for name in paths:
var binding := OpenXRIPBinding.new()
binding.action = actions[name]
binding.binding_path = prefix + paths[name]
bindings.append(binding)
profile.set_bindings(bindings)
map.add_interaction_profile(profile)
var path := "res://openxr_action_map.tres"
for argument in OS.get_cmdline_user_args():
if argument.begins_with("--output="): path = argument.trim_prefix("--output=")
var error := ResourceSaver.save(map, path)
print("FTLVR action map saved: ", error)
quit(error)
+1
View File
@@ -0,0 +1 @@
uid://dmh8m7mtywriy
+214
View File
@@ -0,0 +1,214 @@
"""Read a user's FTL PKG and write local-only data for the tabletop prototype.
This intentionally does not patch the game or copy game data into the repository.
Works with a Steam or GOG ftl.dat that uses the 1.6.x PKG layout.
"""
from __future__ import annotations
import argparse
import json
import struct
import xml.etree.ElementTree as ET
from pathlib import Path
class Pkg:
def __init__(self, path: Path):
self.path = path
self.entries: dict[str, tuple[int, int, int, int]] = {}
with path.open("rb") as source:
header = source.read(16)
if len(header) != 16 or header[:4] != b"PKG\n":
raise ValueError("Not an FTL PKG archive")
header_size, entry_size, count, names_size = struct.unpack(">HHII", header[4:])
if header_size != 16 or entry_size != 20 or count > 100_000:
raise ValueError("Unsupported FTL PKG index")
records = []
for _ in range(count):
record = source.read(20)
if len(record) != 20:
raise ValueError("Truncated FTL PKG index")
_hash = int.from_bytes(record[0:4], "big")
flags = record[4]
name_offset = int.from_bytes(record[5:8], "big")
offset, size, raw_size = struct.unpack(">III", record[8:20])
records.append((flags, name_offset, offset, size, raw_size))
names = source.read(names_size)
source.seek(0, 2)
archive_size = source.tell()
for flags, name_offset, offset, size, raw_size in records:
if name_offset >= len(names) or offset + size > archive_size:
raise ValueError("FTL PKG index points outside archive")
end = names.find(b"\x00", name_offset)
if end < 0:
raise ValueError("Unterminated FTL PKG path")
name = names[name_offset:end].decode("ascii")
self.entries[name] = (flags, offset, size, raw_size)
def read(self, name: str) -> bytes:
flags, offset, size, raw_size = self.entries[name]
if flags != 0 or raw_size != size:
raise ValueError(f"Compressed or unsupported PKG entry: {name}")
with self.path.open("rb") as source:
source.seek(offset)
data = source.read(size)
if len(data) != size:
raise ValueError(f"Truncated PKG entry: {name}")
return data
def parse_layout(raw: bytes) -> dict:
tokens = raw.decode("utf-8-sig").split()
counts = {"X_OFFSET": 1, "Y_OFFSET": 1, "VERTICAL": 1, "ELLIPSE": 4,
"ROOM": 5, "DOOR": 5}
layout: dict = {"rooms": [], "doors": []}
cursor = 0
while cursor < len(tokens):
tag = tokens[cursor].upper()
cursor += 1
if tag not in counts:
raise ValueError(f"Unknown layout token: {tag}")
count = counts[tag]
if cursor + count > len(tokens):
raise ValueError(f"Truncated {tag} record")
values = [int(value) for value in tokens[cursor:cursor + count]]
cursor += count
if tag == "ROOM":
room_id, x, y, w, h = values
layout["rooms"].append({"id": room_id, "x": x, "y": y, "w": w, "h": h})
elif tag == "DOOR":
x, y, a, b, vertical = values
layout["doors"].append({"x": x, "y": y, "a": a, "b": b,
"vertical": bool(vertical)})
else:
layout[tag.lower()] = values[0] if count == 1 else values
if not layout["rooms"]:
raise ValueError("Layout contains no rooms")
return layout
def system_rooms(blueprints: bytes, layout_name: str) -> tuple[str, dict[str, str]]:
root = ET.fromstring(blueprints)
candidates = [node for node in root.findall(".//shipBlueprint")
if node.get("layout") == layout_name]
if not candidates:
return "", {}
preferred = "PLAYER_SHIP_HARD" if layout_name == "kestral" else ""
blueprint = next((node for node in candidates if node.get("name") == preferred), candidates[0])
systems = {}
for node in blueprint.findall("./systemList/*"):
if node.get("start", "true") == "false":
continue
room = node.get("room")
if room is not None:
systems[room] = node.tag
return blueprint.get("name", ""), systems
def png_size(raw: bytes) -> tuple[int, int]:
if len(raw) < 24 or raw[:8] != b"\x89PNG\r\n\x1a\n" or raw[12:16] != b"IHDR":
raise ValueError("Invalid ship PNG header")
return struct.unpack(">II", raw[16:24])
UI_FONTS = ("c&c.font", "JustinFont10.font", "JustinFont11Bold.font")
def extract_fonts(pkg: Pkg, destination: Path) -> list[str]:
"""Keep original SIL bitmap fonts local; the runtime decodes their atlas.
No font converter, system font installation or Godot import is required.
"""
written = []
for name in UI_FONTS:
source = f"fonts/{name}"
if source not in pkg.entries:
continue
raw = pkg.read(source)
if len(raw) < 24 or raw[:5] != b"FONT\x01":
raise ValueError(f"Unsupported native bitmap font: {source}")
path = destination / source
path.parent.mkdir(parents=True, exist_ok=True)
# SIL .font is not Godot's BMFont .font. Load it via our runtime decoder
# and keep the editor from trying to import it as an unrelated format.
(path.parent / ".gdignore").write_text("", encoding="utf-8")
path.write_bytes(raw)
written.append(source)
return written
def build_ship(pkg: Pkg, name: str, destination: Path, image_name: str | None = None,
*, native_image_rect: dict | None = None) -> dict:
import re
image_name = image_name or name
if not all(re.fullmatch(r'[A-Za-z0-9_-]+', part) for part in (name, image_name)):
raise ValueError('Invalid ship asset identifier')
key = name if image_name == name else f'{name}__{image_name}'
layout = parse_layout(pkg.read(f"data/{name}.txt"))
root = ET.fromstring(pkg.read(f"data/{name}.xml"))
image_node = root.find("img")
if image_node is None:
raise ValueError(f"No ship image rectangle for {name}")
layout["name"] = name
layout["image_rect"] = {key: int(image_node.attrib[key]) for key in ("x", "y", "w", "h")}
if native_image_rect:
rectangle = {key: int(native_image_rect[key]) for key in ("x", "y", "w", "h")}
if not (0 < rectangle["w"] <= 8192 and 0 < rectangle["h"] <= 8192):
raise ValueError("Invalid native ship image dimensions")
layout["image_rect"] = rectangle
layout["image_rect_source"] = "native_ship_image"
layout["blueprint"], layout["systems"] = system_rooms(pkg.read("data/blueprints.xml"), name)
layout["weapon_mounts"] = [dict(node.attrib) for node in root.findall("./weaponMounts/mount")]
candidates = [f"img/ship/{image_name}_base.png",
f"img/ships_noglow/{image_name}_base.png",
f"img/ships_glow/{image_name}_base.png"]
available = [(path, pkg.read(path)) for path in candidates if path in pkg.entries]
if not available:
raise ValueError(f"No base image for {name}")
# Glow and noglow sprites often have different transparent borders. Match
# the dimensions FTL actually renders instead of stretching a cropped PNG
# over the larger XML rectangle and extruding a second, smaller silhouette.
rectangle = layout["image_rect"]
expected_size = (rectangle["w"], rectangle["h"])
image_path, image_bytes = next((entry for entry in available if png_size(entry[1]) == expected_size), available[0])
layout["texture_size"] = list(png_size(image_bytes))
layout["image_source_path"] = image_path
destination.mkdir(parents=True, exist_ok=True)
(destination / f"{key}.png").write_bytes(image_bytes)
(destination / f"{key}.json").write_text(json.dumps(layout, indent=2), encoding="utf-8")
return {"name": name, "rooms": len(layout["rooms"]), "doors": len(layout["doors"]),
"blueprint": layout["blueprint"],
"image_path": image_path}
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("archive", type=Path, help="Path to your Steam or GOG ftl.dat")
parser.add_argument("--output", type=Path,
default=Path(__file__).resolve().parents[1] / "local_game_data")
parser.add_argument("--ship", action="append", dest="ships",
help="Ship layout name; repeat for more ships")
args = parser.parse_args()
pkg = Pkg(args.archive)
ships = args.ships or ["kestral", "rebel_long"]
manifest = {"source": str(args.archive), "ships": []}
for ship in ships:
manifest["ships"].append(build_ship(pkg, ship, args.output))
ui_paths = [name for name in pkg.entries if name.endswith(".png") and (
name.startswith(("img/statusUI/", "img/systemUI/", "img/icons/", "img/combatUI/"))
or name in {"img/box_subsystems4.png", "img/box_weapons_bottom4.png",
"img/box_weapons_bottom_label.png", "img/box_weapons_autofire_base.png",
"img/Text_pause1.png", "img/Text_pause2.png"})]
for name in ui_paths:
destination = args.output / "ui" / name
destination.parent.mkdir(parents=True, exist_ok=True)
destination.write_bytes(pkg.read(name))
manifest["ui_assets"] = len(ui_paths)
manifest["fonts"] = extract_fonts(pkg, args.output)
(args.output / "manifest.json").write_text(json.dumps(manifest, indent=2), encoding="utf-8")
print(json.dumps(manifest, indent=2))
if __name__ == "__main__":
main()
+42
View File
@@ -0,0 +1,42 @@
"""Remove only edge-connected cleared black pixels, retaining black UI ink."""
import numpy as np
def clear_world_alpha(pixels):
black = ~np.any(pixels[:, :, :3], axis=2)
height, width = black.shape
parents, edge, runs = [], [], []
previous = []
def root(i):
while parents[i] != i:
parents[i] = parents[parents[i]]
i = parents[i]
return i
for y in range(height):
changes = np.flatnonzero(np.diff(np.pad(black[y].astype(np.int8), (1, 1))))
current = []
cursor = 0
for x0, x1 in zip(changes[::2], changes[1::2]):
i = len(parents)
parents.append(i)
edge.append(y in (0, height-1) or x0 == 0 or x1 == width)
current.append((int(x0), int(x1), i))
runs.append((y, int(x0), int(x1), i))
while cursor < len(previous) and previous[cursor][1] <= x0:
cursor += 1
k = cursor
while k < len(previous) and previous[k][0] < x1:
a, b = root(i), root(previous[k][2])
if a != b:
parents[b] = a
edge[a] = edge[a] or edge[b]
k += 1
previous = current
result = pixels.copy()
result[:, :, 3] = 255
for y, x0, x1, i in runs:
if edge[root(i)]:
result[y, x0:x1, 3] = 0
return result
+4
View File
@@ -0,0 +1,4 @@
extends "res://scripts/main.gd"
var commands: Array = []
func _command(action: String, data: Dictionary) -> void:
commands.append({"action": action, "data": data})
+1
View File
@@ -0,0 +1 @@
uid://dsrpbn3l2ka44
+107
View File
@@ -0,0 +1,107 @@
"""Local FTL lab converters. Never distribute their game-derived output."""
import struct
import zlib
from pathlib import Path
from extract_ftl import Pkg
def path_hash(name: str) -> int:
"""SIL PKG rotate/XOR path hash, documented by Slipstream PkgPack."""
value = 0
for byte in name.lower().encode('ascii'):
value = (((value << 27) | (value >> 5)) ^ byte) & 0xFFFFFFFF
return value
def write_pkg(source: Path, destination: Path, replacements: dict[str, bytes]):
pkg = Pkg(source)
names = sorted(set(pkg.entries) | set(replacements), key=lambda n: (path_hash(n), n.lower()))
name_blob = b''.join(n.encode('ascii') + b'\0' for n in names)
offset = 16 + 20 * len(names) + len(name_blob)
name_offset = 0
index = []
for name in names:
size = len(replacements[name]) if name in replacements else pkg.entries[name][2]
index.append(struct.pack('>IIIII', path_hash(name), name_offset, offset, size, size))
offset += size
name_offset += len(name.encode('ascii')) + 1
if len(name_blob) >= 1 << 24 or offset >= 1 << 32:
raise ValueError('PKG exceeds format bounds')
if source.resolve() == destination.resolve():
raise ValueError('Refusing to overwrite source archive')
with destination.open('wb') as output:
output.write(b'PKG\n' + struct.pack('>HHII', 16, 20, len(names), len(name_blob)))
output.write(b''.join(index))
output.write(name_blob)
for name in names:
output.write(replacements[name] if name in replacements else pkg.read(name))
check = Pkg(destination)
for name, payload in replacements.items():
if check.read(name) != payload:
raise ValueError(f'PKG round trip failed: {name}')
def apply_bps(source: bytes, patch: bytes) -> bytes:
"""Apply a BPS patch with source, patch, and target CRC checks."""
if patch[:4] != b'BPS1' or len(patch) < 16:
raise ValueError('Not a BPS patch')
source_crc, target_crc, patch_crc = struct.unpack('<III', patch[-12:])
if zlib.crc32(source) != source_crc or zlib.crc32(patch[:-4]) != patch_crc:
raise ValueError('Source or patch CRC mismatch')
cursor = 4
def number():
nonlocal cursor
value, shift = 0, 1
while True:
if cursor >= len(patch) - 12:
raise ValueError('Truncated BPS number')
byte = patch[cursor]
cursor += 1
value += (byte & 127) * shift
if byte & 128:
return value
shift <<= 7
value += shift
source_size, target_size, metadata_size = number(), number(), number()
if source_size != len(source) or target_size > 128 * 1024 * 1024:
raise ValueError('Unsupported BPS sizes')
cursor += metadata_size
target = bytearray()
source_relative = target_relative = 0
while len(target) < target_size:
action = number()
mode, count = action & 3, (action >> 2) + 1
if len(target) + count > target_size:
raise ValueError('BPS target overrun')
if mode == 0:
start = len(target)
if start + count > len(source):
raise ValueError('BPS source overrun')
target.extend(source[start:start + count])
elif mode == 1:
if cursor + count > len(patch) - 12:
raise ValueError('BPS literal overrun')
target.extend(patch[cursor:cursor + count])
cursor += count
else:
distance = number()
delta = (distance >> 1) * (-1 if distance & 1 else 1)
if mode == 2:
source_relative += delta
if source_relative < 0 or source_relative + count > len(source):
raise ValueError('BPS source copy out of bounds')
target.extend(source[source_relative:source_relative + count])
source_relative += count
else:
target_relative += delta
for _ in range(count):
if not 0 <= target_relative < len(target):
raise ValueError('BPS target copy out of bounds')
target.append(target[target_relative])
target_relative += 1
if cursor != len(patch) - 12 or zlib.crc32(target) != target_crc:
raise ValueError('BPS target CRC or trailing data mismatch')
return bytes(target)
+21
View File
@@ -0,0 +1,21 @@
"""Set only the isolated lab's canvas and reserved virtual store hotkey."""
import re
def configure(lab):
path=lab/'settings.ini'
text=path.read_text() if path.exists() else ''
values={'fullscreen':0,'last_fullscreen':0,'manual':1,'screen_x':1280,
'screen_y':720,'windowed':1,'stretched':0,'store':291,'pause':32,
'weapon1':49,'weapon2':50,'weapon3':51,'weapon4':52,
'crew_all':113,'loadPositions':13,'savePositions':47,'autofire':118,
'open':122,'close':120,'force_autofire':306,
'shields':97,'engines':115,'oxygen':102,'medbay':100,'weapons':119,'drones':101,
'teleporter':103,'cloaking':104,'mind':107,'hacking':108,'artillery':121,
'activate_cloak':99,'send_tele':116,'ret_tele':114,'start_hacking':110,
'mindControl':109,'activate_battery':98}
for key,value in values.items():
pattern=rf'(?m)^{key}=.*$'
if re.search(pattern,text): text=re.sub(pattern,f'{key}={value}',text)
else: text+=f'\n{key}={value}\n'
path.write_text(text)
+114
View File
@@ -0,0 +1,114 @@
"""Launch the verified isolated game and OpenXR client together on Windows."""
import argparse
import hashlib
import importlib.util
import json
import os
import subprocess
import sys
import time
import winreg
import zipfile
from datetime import datetime
from pathlib import Path
from lab_settings import configure
PROJECT = Path(__file__).resolve().parents[1]
def preflight(config, desktop=False):
for name in ('lab', 'godot', 'hooks'):
if not Path(config[name]).exists(): raise ValueError(f'Missing {name}: {config[name]}')
for module in ('frida', 'PIL', 'numpy'):
if importlib.util.find_spec(module) is None: raise ValueError(f'Missing Python dependency: {module}')
lab = Path(config['lab'])
marker = json.loads((lab/'ftlvr-lab.json').read_text())
hooks = json.loads(Path(config['hooks']).read_text())
if not all(name in hooks.get('rvas',{}) for name in ('GetShiftState','GetCtrlState','ForceAutofireFlag')):
raise ValueError('Regenerate lab hooks.json with tools/resolve_hooks.py for controller modifiers')
if not all(name in hooks.get('rvas',{}) for name in ('OnTextInput','OnTextEvent','TextInputOnRender','TextInputStart')):
raise ValueError('Regenerate lab hooks.json with tools/resolve_hooks.py for native renaming')
if not all(name in hooks.get('rvas',{}) for name in ('CommandGuiRenderStatic','CommandGuiRenderPause','TabbedWindowOnRender','ChoiceBoxOnRender','MouseControlOnRender','StarMapOnRender')):
raise ValueError('Regenerate lab hooks.json with tools/resolve_hooks.py for live native HUD capture')
if not marker.get('activated') or hashlib.sha1((lab/'FTLGame.exe').read_bytes()).hexdigest() != hooks['sha1']:
raise ValueError('Lab is not activated or its executable fingerprint changed')
if not (lab/'Hyperspace.dll').exists(): raise ValueError('Hyperspace is missing')
status = PROJECT/'local_game_data/bridge_status.json'
if status.exists():
previous = json.loads(status.read_text())
if previous.get('connected') and time.time()-previous.get('heartbeat',0)<3:
raise ValueError('An FTL VR bridge is already running. Close that session first.')
runtime = ''
try:
with winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE,r'SOFTWARE\Khronos\OpenXR\1') as key:
runtime = winreg.QueryValueEx(key,'ActiveRuntime')[0]
except OSError: pass
if not desktop and ('steamxr' not in runtime.lower() or not Path(runtime).exists()):
raise ValueError('Select SteamVR as the OpenXR runtime in SteamVR settings, then retry')
return {'lab':str(lab), 'runtime':runtime, 'mode':'desktop' if desktop else 'VR'}
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--config',type=Path,default=PROJECT/'local_game_data/launcher.json')
parser.add_argument('--desktop',action='store_true')
parser.add_argument('--check',action='store_true')
parser.add_argument('--smoke',action='store_true',help='Desktop launch and graceful exit after 3 seconds')
args=parser.parse_args()
if args.smoke: args.desktop=True
config=json.loads(args.config.read_text())
print(json.dumps(preflight(config,args.desktop),indent=2),flush=True)
if args.check: return
local=PROJECT/'local_game_data'; local.mkdir(exist_ok=True)
lab=Path(config['lab'])
# Snapshot only this mod's separate save profile before each launch.
saves=Path(os.environ['USERPROFILE'])/'Documents/My Games/FasterThanLight'
backup=lab/'save-backups'; backup.mkdir(exist_ok=True)
with zipfile.ZipFile(backup/(datetime.now().strftime('%Y%m%d-%H%M%S')+'.zip'),'w',zipfile.ZIP_DEFLATED) as archive:
for path in saves.glob('hs_ftlvr_*'):
if path.is_file(): archive.write(path,path.name)
if (lab/'settings.ini').exists(): archive.write(lab/'settings.ini','settings.ini')
configure(lab)
stop=local/'launcher.stop'
stop.unlink(missing_ok=True)
logs=local/'logs'; logs.mkdir(exist_ok=True)
with (logs/'bridge.log').open('w') as bridge_log, (logs/'client.log').open('w') as client_log:
bridge=subprocess.Popen([sys.executable,str(PROJECT/'tools/run_bridge.py'),
'--lab',str(lab),'--hooks',config['hooks'],'--allow-input','--stop-file',str(stop)],
cwd=PROJECT,stdout=bridge_log,stderr=subprocess.STDOUT,creationflags=subprocess.CREATE_NO_WINDOW)
client=None
try:
deadline=time.monotonic()+55
status=local/'bridge_status.json'
while time.monotonic()<deadline:
if bridge.poll() is not None: raise RuntimeError('Game bridge failed; see local_game_data/logs/bridge.log')
try:
value=json.loads(status.read_text())
if value.get('connected') and value.get('live_state_received') and value.get('capture_received') and time.time()-value['heartbeat']<2: break
except (OSError,ValueError): pass
time.sleep(.1)
else: raise TimeoutError('Game bridge did not become ready')
command=[config['godot'],'--path',str(PROJECT),'--log-file',str(logs/'godot.log')]
if args.desktop: command+=['--xr-mode','off']
if args.smoke: command+=['--quit-after','180','--max-fps','60']
command+=['--']+(['--desktop'] if args.desktop else ['--require-vr'])
print('Starting client. Closing it saves and exits the isolated FTL game.',flush=True)
client=subprocess.Popen(command,cwd=PROJECT,stdout=client_log,stderr=subprocess.STDOUT)
while client.poll() is None and bridge.poll() is None: time.sleep(.25)
if bridge.poll() is not None and client.poll() is None:
client.terminate()
if client.poll() not in (None,0):
raise RuntimeError('VR client exited with an error; see local_game_data/logs/client.log')
finally:
stop.touch()
try: bridge.wait(timeout=15)
except subprocess.TimeoutExpired:
bridge.terminate(); bridge.wait(timeout=5)
if client and client.poll() is None: client.terminate(); client.wait(timeout=5)
if __name__=='__main__':
try: main()
except Exception as error:
print('FTL VR:',error,file=sys.stderr)
sys.exit(1)
+82
View File
@@ -0,0 +1,82 @@
"""Prepare an isolated FTL lab. Activation must be explicitly requested."""
import argparse
import hashlib
import json
import re
import shutil
import subprocess
import zipfile
from pathlib import Path
from lab_formats import apply_bps
from lab_settings import configure
STEAM_1614_SHA1 = 'c58e5283b2c1996fa36158265423f8c94f3a8954'
STEAM_169_SHA1 = 'eadd0a60dbf80c5a7c2a6879f8ae241afb553ade'
ROLLBACK_SHA256 = 'a1b70589c0614ee9aa9c1acd55af527bbec10f3e7af4640579b03ced0bd69f52'
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--game', type=Path, required=True)
parser.add_argument('--lab', type=Path, required=True)
parser.add_argument('--rollback', type=Path, required=True)
parser.add_argument('--hyperspace', type=Path, required=True)
parser.add_argument('--mod-manager', type=Path, required=True)
parser.add_argument('--activate', action='store_true', help='Install loader and patched data into the isolated lab')
args = parser.parse_args()
game, lab = args.game.resolve(), args.lab.resolve()
if game == lab or game in lab.parents or lab in game.parents:
raise ValueError('Lab must be separate from the installed game directory')
lab.mkdir(parents=True, exist_ok=True)
marker = lab / 'ftlvr-lab.json'
if any(lab.iterdir()) and not marker.exists():
raise ValueError('Refusing to populate a nonempty, unrecognized lab')
original = (game / 'FTLGame.exe').read_bytes()
if hashlib.sha1(original).hexdigest() != STEAM_1614_SHA1:
raise ValueError('Only the verified Steam Windows 1.6.14 build is supported by this lab preparer')
rollback = args.rollback.read_bytes()
if hashlib.sha256(rollback).hexdigest() != ROLLBACK_SHA256:
raise ValueError('Rollback patch differs from the inspected official patch')
patched = apply_bps(original, rollback)
if hashlib.sha1(patched).hexdigest() != STEAM_169_SHA1:
raise ValueError('Rollback output is not the expected Steam Windows 1.6.9 build')
marker.write_text(json.dumps({'source_game': str(game), 'source_sha1': STEAM_1614_SHA1,
'save_prefix': 'ftlvr', 'activated': False}, indent=2), encoding='utf-8')
for name in ['FTLGame.exe', 'ftl.dat', 'bass.dll', 'bassmix.dll', 'steam_api.dll', 'steam_wrapper.dll']:
if not (lab / name).exists():
shutil.copy2(game / name, lab / name)
(lab / 'FTLGame_1.6.9.prepared').write_bytes(patched)
replacements = {}
with zipfile.ZipFile(args.hyperspace / 'Hyperspace.ftl') as bundle:
replacements['data/hyperspace.xml'] = bundle.read('data/hyperspace.xml')
config = replacements['data/hyperspace.xml'].decode('utf-8-sig')
config = re.sub(r'<saveFile>.*?</saveFile>', '<saveFile><prefix>ftlvr</prefix><inheritMode>2</inheritMode></saveFile>', config, flags=re.S)
config = config.replace('<scripts/>', '<scripts><script>data/ftlvr_bridge.lua</script></scripts>')
replacements['data/hyperspace.xml'] = config.encode('utf-8')
replacements['data/ftlvr_bridge.lua'] = (Path(__file__).parents[1] / 'bridge/ftlvr_bridge.lua').read_bytes()
mod_file = lab / 'tabletop-bridge.ftl'
with zipfile.ZipFile(mod_file, 'w', zipfile.ZIP_DEFLATED) as output:
for name, payload in replacements.items():
output.writestr(name, payload)
staging = lab / 'prepared-resources'
staging.mkdir(exist_ok=True)
shutil.copy2(game / 'ftl.dat', staging / 'ftl.dat')
subprocess.run([str(args.mod_manager.resolve()), 'patch', '--data-dir', str(staging),
str((args.hyperspace / 'Hyperspace.ftl').resolve()), str(mod_file)], check=True)
shutil.copy2(staging / 'ftl.dat', lab / 'ftl.vr.prepared')
marker.write_text(json.dumps({'source_game': str(game), 'source_sha1': STEAM_1614_SHA1,
'rollback_sha1': STEAM_169_SHA1, 'save_prefix': 'ftlvr',
'activated': args.activate}, indent=2), encoding='utf-8')
if args.activate:
shutil.copy2(lab / 'FTLGame_1.6.9.prepared', lab / 'FTLGame.exe')
shutil.copy2(lab / 'ftl.vr.prepared', lab / 'ftl.dat')
binary_dir = args.hyperspace / 'Windows - Extract these files into where FTLGame.exe is'
for name in ['Hyperspace.dll', 'xinput1_4.dll']:
shutil.copy2(binary_dir / name, lab / name)
configure(lab)
print(json.dumps({'lab': str(lab), 'prepared_entries': len(replacements), 'activated': args.activate}))
if __name__ == '__main__':
main()
+16
View File
@@ -0,0 +1,16 @@
extends "res://scripts/main.gd"
var profile_poll_ms: Array[float] = []
var profile_process_ms: Array[float] = []
func _poll_bridge_state() -> void:
var started := Time.get_ticks_usec()
super._poll_bridge_state()
profile_poll_ms.append(float(Time.get_ticks_usec() - started) / 1000.0)
func _process(delta: float) -> void:
var started := Time.get_ticks_usec()
super._process(delta)
profile_process_ms.append(float(Time.get_ticks_usec() - started) / 1000.0)
+96
View File
@@ -0,0 +1,96 @@
extends SceneTree
var elapsed := 0.0
var sample_seconds := 10.0
var counts := {"screen": 0, "hud": 0}
var frame_times: Array[float] = []
var surfaces: Array[Node3D] = []
var output_name := "tactical-client-profile.json"
var full_main := false
var capture_ages: Array[float] = []
var process_times: Array[float] = []
var main: Node3D
var mock_tracker: XRControllerTracker
func _initialize() -> void:
Engine.max_fps = 90
for value in OS.get_cmdline_user_args():
if value.begins_with("--output="): output_name = value.trim_prefix("--output=")
if value.begins_with("--seconds="): sample_seconds = float(value.trim_prefix("--seconds="))
if value == "--full-main": full_main = true
call_deferred("_run")
func _run() -> void:
if full_main:
main = load(ProjectSettings.get_setting("application/run/main_scene")).instantiate()
main.set_script(load("res://tools/profile_main.gd"))
root.add_child(main)
# Exercise the complete VR presentation and CPU update paths in a
# desktop Vulkan viewport. This does not measure stereo headset FPS.
main.xr_active = true
main.initial_table_placed = true
mock_tracker = XRControllerTracker.new()
mock_tracker.type = XRServer.TRACKER_CONTROLLER
mock_tracker.name = "left_hand"
mock_tracker.set_pose("default", Transform3D(Basis.IDENTITY, Vector3(-0.4, 2.05, 2.0)), Vector3.ZERO, Vector3.ZERO, XRPose.XR_TRACKING_CONFIDENCE_HIGH)
XRServer.add_tracker(mock_tracker)
main._poll_bridge_state()
surfaces.assign([main.map_surface, main.hud_surface])
else:
var camera := Camera3D.new()
camera.position = Vector3(0, 0, 3)
camera.current = true
root.add_child(camera)
for entry in [["screen", "screen_frame.png"], ["hud", "hud_frame.png"]]:
var surface: Node3D = load("res://scripts/hud_surface.gd").new()
surface.frame_file = entry[1]
root.add_child(surface)
surfaces.append(surface)
for i in range(surfaces.size()):
surfaces[i].canvas.texture_changed.connect(_count.bind("screen" if i == 0 else "hud"))
await create_timer(1.0).timeout
counts = {"screen": 0, "hud": 0}
capture_ages.clear()
if full_main:
main.profile_poll_ms.clear()
main.profile_process_ms.clear()
var started := Time.get_ticks_usec()
while elapsed < sample_seconds:
var before := Time.get_ticks_usec()
await process_frame
frame_times.append(float(Time.get_ticks_usec() - before) / 1000.0)
process_times.append(Performance.get_monitor(Performance.TIME_PROCESS) * 1000.0)
elapsed = float(Time.get_ticks_usec() - started) / 1000000.0
frame_times.sort()
capture_ages.sort()
process_times.sort()
var report := {"seconds": elapsed, "screen_texture_updates": counts.screen,
"hud_texture_updates": counts.hud, "screen_delivered_fps": counts.screen / elapsed,
"hud_delivered_fps": counts.hud / elapsed, "client_fps": frame_times.size() / elapsed,
"client_frame_ms_p95": frame_times[int(frame_times.size() * 0.95)],
"gpu": RenderingServer.get_video_adapter_name(), "renderer": RenderingServer.get_current_rendering_method(),
"full_main": full_main, "stereo_headset_measurement": false,
"client_process_ms_p95": process_times[int(process_times.size() * 0.95)],
"screen_capture_age_ms_p95": capture_ages[int(capture_ages.size() * 0.95)] if not capture_ages.is_empty() else -1.0,
"screen_transport_size": str(surfaces[0].canvas.frame_image.get_size()) if surfaces[0].canvas.frame_image != null else "missing",
"screen_viewport_size": str(surfaces[0].viewport.size)}
if full_main:
main.profile_poll_ms.sort()
main.profile_process_ms.sort()
report.native_poll_ms_p95 = main.profile_poll_ms[int(main.profile_poll_ms.size() * 0.95)]
report.native_poll_ms_max = main.profile_poll_ms.back()
report.main_cpu_ms_p95 = main.profile_process_ms[int(main.profile_process_ms.size() * 0.95)]
report.main_cpu_ms_max = main.profile_process_ms.back()
var file := FileAccess.open("res://local_game_data/" + output_name, FileAccess.WRITE)
file.store_string(JSON.stringify(report, "\t"))
file.close()
print("TACTICAL_CLIENT_PROFILE ", JSON.stringify(report))
quit()
func _count(stream: String) -> void:
counts[stream] += 1
if stream == "screen" and not surfaces.is_empty() and surfaces[0].canvas.capture_time_unix > 0:
capture_ages.append((Time.get_unix_time_from_system() - surfaces[0].canvas.capture_time_unix) * 1000.0)
+46
View File
@@ -0,0 +1,46 @@
"""Measure native counters and actual published files during a scoped QA run."""
import argparse
import json
import time
from pathlib import Path
def read_json(path):
for _ in range(20):
try:
return json.loads(path.read_text(encoding='utf-8'))
except (PermissionError,json.JSONDecodeError):
time.sleep(.01)
raise RuntimeError('Could not read profile state')
def measure(local,seconds):
first=read_json(local/'bridge_status.json')['native_capture']['performance']
changes={name:0 for name in ('screen_frame.png','hud_frame.png','screen_frame.rgba','hud_frame.rgba')}
previous={name:(local/name).stat().st_mtime_ns if (local/name).exists() else 0 for name in changes}
start=time.monotonic()
while time.monotonic()-start<seconds:
for name in changes:
try: modified=(local/name).stat().st_mtime_ns
except (FileNotFoundError,PermissionError): continue
if modified!=previous[name]: changes[name]+=1;previous[name]=modified
time.sleep(.003)
elapsed=time.monotonic()-start
latest=read_json(local/'bridge_status.json')
last=latest['native_capture']['performance']
native_seconds=(last['elapsed_ms']-first['elapsed_ms'])/1000
counts={key:last[key]-first[key] for key in ('native_loops','native_swaps','frames','hud_frames','capture_ms','hud_ms','read_ms','loop_ms','bytes_sent')}
return {'seconds':elapsed,'native_seconds':native_seconds,'native':counts,
'native_fps':counts['native_swaps']/native_seconds,'capture_fps':counts['frames']/native_seconds,
'hud_capture_fps':counts['hud_frames']/native_seconds,'capture_cpu_ms_per_frame':counts['capture_ms']/max(1,counts['frames']),
'published_files':changes,'published_fps':{name:count/elapsed for name,count in changes.items()},'errors':latest['errors']}
if __name__=='__main__':
parser=argparse.ArgumentParser(description=__doc__)
parser.add_argument('--seconds',type=float,default=10)
parser.add_argument('--output',default='tactical-native-profile.json')
parser.add_argument('--local',type=Path,default=Path(__file__).resolve().parents[1]/'local_game_data')
args=parser.parse_args();report=measure(args.local,args.seconds)
(args.local/args.output).write_text(json.dumps(report,indent=2),encoding='utf-8')
print(json.dumps(report,indent=2))
+27
View File
@@ -0,0 +1,27 @@
extends SceneTree
func _initialize() -> void:
call_deferred("record")
func record() -> void:
var scene: Node3D = load("res://main.tscn").instantiate()
root.add_child(scene)
await create_timer(1.0).timeout
if not scene.bridge_connected:
push_error("Live bridge required for this recording")
quit(1)
return
var directory := ProjectSettings.globalize_path("res://local_game_data/recording")
DirAccess.make_dir_recursive_absolute(directory)
for i in range(200):
var progress := float(i) / 199.0
# Move the encounter to inspect its actual geometry; HUD stays on camera.
scene.tabletop_root.rotation.y = sin(progress * TAU) * 0.9
if i == 80:
scene.enemy_ship.inspect_rooms = true
scene.enemy_ship.apply_live(scene.enemy_ship.live_data)
await RenderingServer.frame_post_draw
root.get_texture().get_image().save_png(directory.path_join("frame-%04d.png" % i))
await create_timer(0.1).timeout
print("LIVE_RECORDING frames=200 fps=10 simulation_paused=", scene.paused)
quit()
+1
View File
@@ -0,0 +1 @@
uid://bwpyp25yh1tmy
+159
View File
@@ -0,0 +1,159 @@
"""Resolve community-documented CApp signatures against the owned lab binary."""
import argparse
import hashlib
import json
import re
import struct
from pathlib import Path
import capstone
def resolve(executable: Path, signatures: Path) -> dict:
data = executable.read_bytes()
pe = struct.unpack_from('<I', data, 0x3C)[0]
count = struct.unpack_from('<H', data, pe + 6)[0]
optional_size = struct.unpack_from('<H', data, pe + 20)[0]
sections = pe + 24 + optional_size
code = None
for i in range(count):
offset = sections + i * 40
if data[offset:offset+8].rstrip(b'\0') == b'.text':
_, rva, length, raw = struct.unpack_from('<IIII', data, offset + 8)
code = data[raw:raw+length]
break
if code is None:
raise ValueError('Missing .text section')
decoder = capstone.Cs(capstone.CS_ARCH_X86, capstone.CS_MODE_32)
previous_end = 0
hooks = {}
for signature, name in re.findall(r'"([.0-9a-fA-F?]+)":[^\n]*\n[^\n]*CApp::(\w+)\(', signatures.read_text()):
pattern = signature.lstrip('.')
regex = b''.join(b'.' if pattern[i:i+2] == '??' else re.escape(bytes.fromhex(pattern[i:i+2]))
for i in range(0, len(pattern), 2))
match = re.search(regex, code[previous_end if signature.startswith('.') else 0:], re.S)
if not match:
raise ValueError(f'No signature match for CApp::{name}')
start = match.start() + (previous_end if signature.startswith('.') else 0)
hooks[name] = rva + start
previous_end = start + len(pattern) // 2
for instruction in decoder.disasm(code[start:start+32768], rva + start):
if instruction.mnemonic in ('ret', 'retf'):
previous_end = instruction.address + instruction.size - rva
break
if name == 'OnExecute':
break
needed = ['OnLoop','OnRender','OnInputFocus','OnMouseMove','OnLButtonDown','OnLButtonUp','OnRButtonDown','OnRButtonUp','OnKeyDown','OnKeyUp','OnTextInput','OnTextEvent']
if not all(name in hooks for name in needed):
raise ValueError('Missing required input/render hooks')
# Modifier getters are documented in the matching CEvent signature file.
# Resolve independently and require uniqueness; never use a guessed address.
event_source = (signatures.parent/'CEvent.zhl').read_text()
for signature, name in re.findall(r'"([.0-9a-fA-F?]+)":[^\n]*\n[^\n]*CEvent::(GetShiftState|GetCtrlState)\(', event_source):
pattern=signature.lstrip('.')
regex=b''.join(b'.' if pattern[i:i+2]=='??' else re.escape(bytes.fromhex(pattern[i:i+2]))
for i in range(0,len(pattern),2))
matches=list(re.finditer(regex,code,re.S))
if len(matches)!=1: raise ValueError(f'Ambiguous modifier signature: {name}')
hooks[name]=rva+matches[0].start()
needed.append(name)
if not all(name in hooks for name in ('GetShiftState','GetCtrlState')):
raise ValueError('Missing native modifier getters')
text_source=(signatures.parent/'TextInput.zhl').read_text()
for signature,name in re.findall(r'"([.0-9a-fA-F?]+)":[^\n]*\n[^\n]*TextInput::(OnRender|GetActive|Start)\(',text_source):
pattern=signature.lstrip('.')
regex=b''.join(b'.' if pattern[i:i+2]=='??' else re.escape(bytes.fromhex(pattern[i:i+2]))
for i in range(0,len(pattern),2))
matches=list(re.finditer(regex,code,re.S))
if len(matches)!=1: raise ValueError(f'Ambiguous TextInput signature: {name}')
hook_name='TextInput'+name
hooks[hook_name]=rva+matches[0].start()
needed.append(hook_name)
if not all(name in hooks for name in ('TextInputGetActive','TextInputOnRender','TextInputStart')):
raise ValueError('Missing native text-entry hooks')
active_offset=hooks['TextInputGetActive']-rva
first=next(decoder.disasm(code[active_offset:active_offset+16],hooks['TextInputGetActive']))
if first.mnemonic!='movzx' or first.op_str!='eax, byte ptr [ecx + 0x38]':
raise ValueError('Native TextInput active flag offset changed')
# Independent HUD render pass uses only UI methods; no second game/world
# render advances ship, jump or projectile animations.
for class_name,method_names in {'CommandGui':('RenderStatic','RenderPause'),
'TabbedWindow':('OnRender',),'ChoiceBox':('OnRender',),
'MouseControl':('OnRender',),'StarMap':('OnRender',)}.items():
source=(signatures.parent/(class_name+'.zhl')).read_text()
for signature,name in re.findall(r'"([.0-9a-fA-F?]+)":[^\n]*\n[^\n]*'+class_name+r'::(\w+)\(',source):
if name not in method_names: continue
pattern=signature.lstrip('.')
regex=b''.join(b'.' if pattern[i:i+2]=='??' else re.escape(bytes.fromhex(pattern[i:i+2]))
for i in range(0,len(pattern),2))
matches=list(re.finditer(regex,code,re.S))
if len(matches)!=1: raise ValueError(f'Ambiguous HUD signature: {class_name}::{name}')
hook_name=class_name+name
hooks[hook_name]=rva+matches[0].start()
needed.append(hook_name)
if not all(class_name+name in hooks for name in method_names):
raise ValueError('Missing isolated native HUD render hooks for '+class_name)
def method_address(class_name,name):
source=(signatures.parent/(class_name+'.zhl')).read_text()
signature=re.search(r'"([.0-9a-fA-F?]+)":[^\n]*\n[^\n]*'+class_name+r'::'+name+r'\(',source).group(1)
pattern=signature.lstrip('.')
regex=b''.join(b'.' if pattern[i:i+2]=='??' else re.escape(bytes.fromhex(pattern[i:i+2]))
for i in range(0,len(pattern),2))
matches=list(re.finditer(regex,code,re.S))
if len(matches)!=1: raise ValueError(f'Ambiguous structure probe: {class_name}::{name}')
return rva+matches[0].start()
command_loop=method_address('CommandGui','OnLoop')
system_loop=method_address('SystemControl','OnLoop')
instructions=list(decoder.disasm(code[command_loop-rva:command_loop-rva+4096],command_loop))
system_offsets=[]
for index,instruction in enumerate(instructions):
if instruction.mnemonic=='call' and instruction.op_str==hex(system_loop):
previous=instructions[index-1]
match=re.fullmatch(r'ecx, \[ebx \+ (0x[0-9a-f]+)\]',previous.op_str)
if previous.mnemonic=='lea' and match: system_offsets.append(int(match.group(1),16))
if system_offsets!=[0x320]: raise ValueError('CommandGui SystemControl offset changed')
app_loop=list(decoder.disasm(code[hooks['OnLoop']-rva:hooks['OnLoop']-rva+4096],hooks['OnLoop']))
app_offsets=[]
for index,instruction in enumerate(app_loop):
if instruction.mnemonic=='call' and instruction.op_str==hex(command_loop):
previous=app_loop[index-1]
if previous.mnemonic=='mov' and previous.op_str=='ecx, dword ptr [ebx + 8]':app_offsets.append(8)
if app_offsets!=[8]: raise ValueError('CApp GUI pointer offset changed')
get_id=method_address('ShipSystem','GetId')
get_id_start=next(decoder.disasm(code[get_id-rva:get_id-rva+8],get_id))
if get_id_start.mnemonic!='mov' or get_id_start.op_str!='eax, dword ptr [ecx + 0x40]':
raise ValueError('ShipSystem id offset changed')
get_box=method_address('SystemControl','GetSystemBox')
box_code=list(decoder.disasm(code[get_box-rva:get_box-rva+180],get_box))
if not any(i.mnemonic=='mov' and i.op_str=='ecx, dword ptr [eax + 0x4c]' for i in box_code):
raise ValueError('SystemBox system pointer offset changed')
# Position and vector offsets follow the documented win32 SystemControl
# and SystemBox structures; the surrounding method accesses are verified.
offsets={'app_gui':8,'gui_system_control':system_offsets[0],'system_control_position':0x28,
'system_control_boxes':8,'system_box_position':4,'system_box_system':0x4c,'system_id':0x40}
# Both key handlers set/clear the same force_autofire flag. Validate the
# instructions before exposing its read-only diagnostic address.
def flag_addresses(name, value):
start=hooks[name]-rva
addresses=[]
for instruction in decoder.disasm(code[start:start+600],hooks[name]):
if instruction.mnemonic=='mov' and instruction.op_str.startswith('byte ptr [0x') and instruction.op_str.endswith(', '+str(value)):
absolute=int(instruction.op_str.split('[0x')[1].split(']')[0],16)
addresses.append(absolute-struct.unpack_from('<I',data,pe+24+28)[0])
return addresses
flags=set(flag_addresses('OnKeyDown',1)) & set(flag_addresses('OnKeyUp',0))
if len(flags)!=1: raise ValueError('Ambiguous force_autofire state flag')
hooks['ForceAutofireFlag']=flags.pop()
needed.append('ForceAutofireFlag')
return {'sha1': hashlib.sha1(data).hexdigest(), 'rvas': {name: hooks[name] for name in needed},'offsets':offsets}
if __name__ == '__main__':
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('executable', type=Path)
parser.add_argument('signatures', type=Path)
parser.add_argument('output', type=Path)
args = parser.parse_args()
result = resolve(args.executable, args.signatures)
args.output.write_text(json.dumps(result, indent=2), encoding='utf-8')
print(json.dumps(result, indent=2))
+667
View File
@@ -0,0 +1,667 @@
"""Run the isolated FTL process bridge. No global desktop input is generated."""
import argparse
import hashlib
import json
import os
import queue
import struct
import subprocess
import threading
import time
import uuid
from collections import deque
from pathlib import Path
import frida
from PIL import Image
from extract_ftl import Pkg, build_ship
from hud_alpha import clear_world_alpha
def atomic_json(path, value):
temporary = path.with_suffix('.pending')
temporary.write_text(json.dumps(value, ensure_ascii=False), encoding='utf-8')
replace_when_available(temporary, path)
def replace_when_available(source, destination):
# Windows readers may briefly deny replacement while opening a frame/state.
for attempt in range(20):
try:
os.replace(source, destination)
return
except PermissionError:
time.sleep(0.005)
os.replace(source, destination)
FRAME_HEADER = struct.Struct('<4sIIIIQd')
def raw_frame_bytes(image, sequence, captured_at):
"""Top-down RGBA; dimensions describe transport, never native input space."""
if image.mode != 'RGBA': image = image.convert('RGBA')
width, height = image.size
return FRAME_HEADER.pack(b'FVR1', 1, width, height, 4, sequence, captured_at) + image.tobytes()
class FramePublisher:
"""Live frames avoid PNG compression; occasional PNGs support capture tools."""
def __init__(self, local):
self.local = local
self.lock = threading.Lock()
self.sequences = {}
self.preview_times = {}
self.previews = {}
self.closed = threading.Event()
self.preview_worker = threading.Thread(target=self._preview_frames,daemon=True)
self.preview_worker.start()
def publish(self, name, image, captured_at):
with self.lock:
sequence = self.sequences.get(name, 0) + 1
self.sequences[name] = sequence
temporary = self.local / (name + '.pending.rgba')
temporary.write_bytes(raw_frame_bytes(image, sequence, captured_at))
replace_when_available(temporary, self.local / (name + '.rgba'))
now = time.monotonic()
if now - self.preview_times.get(name, 0) >= 1.0:
self.previews[name] = image
self.preview_times[name] = now
def _preview_frames(self):
while not self.closed.wait(0.02):
with self.lock:
previews, self.previews = self.previews, {}
for name, image in previews.items():
temporary = self.local / (name + '.pending.png')
image.save(temporary, compress_level=1)
replace_when_available(temporary, self.local / (name + '.png'))
def close(self):
self.closed.set()
self.preview_worker.join(timeout=2)
def decode_state_line(line):
prefix = 'FTLVR_STATE '
if prefix not in line:
return None
content = line.split(prefix, 1)[1].strip()
result = json.loads(content)
if result.get('protocol') != 2 or result.get('source') != 'hyperspace':
raise ValueError('Unsupported FTL snapshot protocol')
if not isinstance(result.get('shots'), list):
result['shots'] = []
if not isinstance(result.get('projectiles'), list):
result['projectiles'] = []
for side in ('player','enemy'):
if isinstance(result.get(side),dict):
for field in ('rooms','crew','weapons','doors','drones','system_status'):
if not isinstance(result[side].get(field),list): result[side][field]=[]
for room in result[side]['rooms']:
for field in ('fire_tiles','breach_tiles'):
if not isinstance(room.get(field),list): room[field]=[]
if isinstance(result.get('dialog'),dict) and not isinstance(result['dialog'].get('choices'),list):
result['dialog']['choices']=[]
# vCrewList ownership/current-ship membership can differ during boarding.
# Render and select each surviving crew member on its actual current ship.
crew_by_id={c['id']:c for side in ('player','enemy') for c in (result.get(side) or {}).get('crew',[])}
for side,ship_id in (('player',0),('enemy',1)):
if isinstance(result.get(side),dict):
result[side]['crew']=[c for c in crew_by_id.values() if c.get('ship')==ship_id]
drones_by_id={d['id']:d for side in ('player','enemy') for d in (result.get(side) or {}).get('drones',[])}
for side,ship_id in (('player',0),('enemy',1)):
if isinstance(result.get(side),dict):
result[side]['drones']=[d for d in drones_by_id.values() if d.get('space')==ship_id]
return result
def room_pixel(state, ship, room_id):
data = state.get(ship) or {}
room = next((r for r in data.get('rooms', []) if int(r['id']) == int(room_id)), None)
if room is None:
raise ValueError('Room is not in the current FTL ship')
origin = state[ship + '_origin']
center = room['center']
return round(origin['x'] + center['x']), round(origin['y'] + center['y'])
def full_screen_capture(state):
# Native shops, upgrades, crew and equipment are world panels, never HUDs.
return not state.get('ready') or state.get('ui_mode','menu') == 'menu'
def world_gameplay_available(state):
"""Tactical is a view of gameplay; real native modal windows block commands."""
return bool(state.get('ready') and not state.get('blocking_ui') and not state.get('event_open') and not state.get('map_open')
and not state.get('panel_open') and not state.get('transition')
and (state.get('ui_mode')=='game' or
(state.get('tactical') and state.get('ui_mode')=='screen')))
def remove_enemy_hud(pixels):
"""Remove the entire native target frame, even on cached/out-of-combat HUDs."""
result=pixels.copy()
result[40:602,872:,3]=0
return result
def remove_pause_overlay(pixels):
"""The VR client shows pause from native state, never from a cached glyph."""
result=pixels.copy()
result[520:598,510:770,3]=0
return result
def native_window_frame(pixels):
"""Keep native window contours, including detached boxes, and discard HUD.
The native renderer already clips ships and environment. Edge-connected
cleared black becomes transparent; enclosed black window ink is retained.
SELL includes a detached drop box and an open gap at screen center. Select
substantial framed components, then keep their enclosed ink; requiring an
opaque center pixel loses SELL, while filling whole bounds leaks PAUSED.
"""
import numpy as np
result=pixels.copy()
# At the fixed native 1280x720 viewport, floating window tabs start at
# y=83. Remove the separate top HUD before finding components: its jump
# button glow can touch the tab frame and otherwise expand the crop.
result[:80,:,3]=0
# Native windows have bright connected borders. The dimmed background HUD
# can overlap a SELL drop box; exclude its dark connector pixels while
# locating frames, then retain the original ink inside each chosen frame.
opaque=np.max(result[:,:,:3],axis=2)>=80
opaque[:80,:]=False
center=(opaque.shape[1]//2,opaque.shape[0]//2)
# Union horizontal runs rather than flood filling every window pixel in
# Python: large opaque windows must retain the bridge's 10 Hz cadence.
parents,runs,previous=[],[],[]
def root(index):
while parents[index]!=index:
parents[index]=parents[parents[index]]
index=parents[index]
return index
for y,row in enumerate(opaque):
changes=np.flatnonzero(np.diff(np.pad(row.astype(np.int8),(1,1))))
current=[]
cursor=0
for x0,x1 in zip(changes[::2],changes[1::2]):
index=len(parents)
parents.append(index)
current.append((x0,x1,index))
runs.append((y,x0,x1,index))
while cursor<len(previous) and previous[cursor][1]<=x0: cursor+=1
k=cursor
while k<len(previous) and previous[k][0]<x1:
a,b=root(index),root(previous[k][2])
if a!=b: parents[b]=a
k+=1
previous=current
bounds={}
for y,x0,x1,index in runs:
key=root(index)
if key not in bounds: bounds[key]=[x0,y,x1,y+1,0]
box=bounds[key]
box[0]=min(box[0],x0);box[1]=min(box[1],y)
box[2]=max(box[2],x1);box[3]=max(box[3],y+1);box[4]+=x1-x0
candidates=[b for b in bounds.values() if b[0]<=center[0]<b[2] and b[1]<=center[1]<b[3]
and b[2]-b[0]>=100 and b[3]-b[1]>=80]
if not candidates:
result[:,:,3]=0
return result
frame=max(candidates,key=lambda box:box[4])
# SELL's drop target and the original item information windows are
# separate native components. Keep their whole frames as well. Native
# crew/weapon HUD widgets are narrower or below the window area.
windows=[frame]+[b for b in bounds.values() if 80<=b[1]<600 and b[3]<=720 and
b[2]-b[0]>=120 and b[3]-b[1]>=60 and b[4]>=3000 and
not (b[0]>=875 and b[1]<100 and b[3]-b[1]>300)]
keep=np.zeros(opaque.shape,dtype=bool)
for x0,y0,x1,y1,_ in windows:keep[y0:y1,x0:x1]=True
# Fill only holes enclosed by the native bright window contours, rather
# than the entire bounding rectangle. The open gap below SELL/crew windows
# contains the dim native PAUSED glyph; it belongs to the background.
contour=np.zeros_like(result)
contour[(opaque & keep),:3]=255
result[:,:,3]=clear_world_alpha(contour)[:,:,3]
return result
def translate_command(command, state):
modifiers=command.get('data',{}).get('modifiers',{})
if not isinstance(modifiers,dict) or any(k not in ('shift','control') for k in modifiers):
raise ValueError('Unsupported modifier')
result=_translate_command(command,state)
for instruction in result:
instruction['modifiers']={key:False if command['action']=='system_power' else bool(modifiers.get(key,False))
for key in ('shift','control')}
return result
def _translate_command(command, state):
action, data = command['action'], command.get('data', {})
if action=='text_input':
entry=state.get('text_entry') or {}
if not entry.get('active') or not data.get('entry_id') or str(data['entry_id'])!=str(entry.get('id')):
raise ValueError('Native rename input is no longer active')
operation=data.get('op')
if operation=='insert':
value=data.get('text','')
if not isinstance(value,str) or not 1<=len(value)<=32 or any(not c.isprintable() for c in value):
raise ValueError('Invalid rename text')
return [{'type':'text','codepoint':ord(character),'entry_id':entry.get('id')} for character in value]
if operation=='cancel':
return [{'type':'text_cancel','entry_id':entry.get('id')}]
events={'confirm':0,'clear':2,'backspace':3,'delete':4,'left':5,'right':6,'home':7,'end':8}
if operation not in events: raise ValueError('Unsupported text operation')
return [{'type':'text_event','event':events[operation],'entry_id':entry.get('id')}]
if action == 'event_choice':
dialog=state.get('dialog') or {}
index=int(data['index'])
choices=dialog.get('choices',[])
if not state.get('event_open') or str(data.get('dialog_id')) != str(dialog.get('id')):
raise ValueError('Event dialog changed before selection')
if not 0 <= index < min(8,len(choices)) or not choices[index].get('enabled',True):
raise ValueError('Event choice unavailable')
return [{'type':'key','key':49+index}]
if action=='navigate' and data.get('screen') in ('menu','tactical'):
return [{'type':'key','key':27 if data['screen']=='menu' else 289}]
if action in ('ui_mouse', 'ui_move'):
x, y = int(data['x']), int(data['y'])
if not (0 <= x < 1280 and 0 <= y < 720):
raise ValueError('UI coordinates outside the original game canvas')
return [{'type': 'move' if action == 'ui_move' else 'mouse', 'x': x, 'y': y,
'button': data.get('button', 'left'), 'phase': data.get('phase', 'click')}]
if action == 'key':
allowed = {13,27,32,47,97,98,99,100,101,102,103,104,105,106,107,108,
109,110,112,113,114,115,116,117,118,119,120,121,122,
48,49,50,51,52,53,54,55,56,57,289,290,291}
key = int(data['key'])
if key not in allowed:
raise ValueError('Unsupported game hotkey')
return [{'type': 'key', 'key': key}]
if action == 'cancel':
if not world_gameplay_available(state): return [{'type':'key','key':27}]
return [{'type':'mouse','button':'right','phase':'click','x':640,'y':719},
{'type':'mouse','button':'left','phase':'up','x':640,'y':719},
{'type':'mouse','button':'left','phase':'click','x':640,'y':719}]
if not world_gameplay_available(state):
raise ValueError('World command requires active gameplay')
if action == 'pause':
return [] if bool(data['paused']) == bool(state['paused']) else [{'type': 'key', 'key': 32}]
if action == 'move_crew' or action == 'select_crew':
crew = next((c for side in ('player','enemy') for c in (state.get(side) or {}).get('crew',[])
if str(c['id']) == str(data['crew_id'])), None)
if not crew or not crew['controllable'] or crew['ship'] not in (0,1):
raise ValueError('Crew is not controllable on a current ship')
side='player' if crew['ship']==0 else 'enemy'
origin = state[side+'_origin']
commands = [{'type':'mouse','button':'left','phase':'click',
'x':round(origin['x']+crew['x']), 'y':round(origin['y']+crew['y'])}]
if action == 'move_crew':
if crew.get('selected'): commands=[]
x,y = room_pixel(state, side, data['room_id'])
commands.append({'type':'mouse','button':'right','phase':'click','x':x,'y':y})
return commands
if action == 'target_room':
target=state.get('targeting') or {}
active=target.get('active',int(state.get('weapon_selected',-1))>=0)
side=data.get('ship','enemy')
ships=target.get('ships',['enemy'])
if (side=='enemy' and not state.get('combat')) or not active or side not in ships:
raise ValueError('Select an actual FTL weapon or targeted system before targeting this ship')
x,y = room_pixel(state, side, data['room_id'])
return [{'type':'mouse','button':'left','phase':data.get('phase','click'),'x':x,'y':y}]
if action == 'system_power':
system_id=data.get('system_id')
system_key=data.get('system_key')
direction=data.get('direction')
if direction not in (-1,1): raise ValueError('System power changes must be one native click')
rows=(state.get('player') or {}).get('system_status',[])
row=next((r for r in rows if (system_id is not None and r.get('id')==int(system_id)) or
(system_id is None and system_key and r.get('key')==system_key)),None)
if not row or not row.get('powerable') or not row.get('power_button'):
raise ValueError('System power control is unavailable in the native game')
point=row['power_button']
return [{'type':'mouse','button':'left' if direction>0 else 'right','phase':'click',
'x':round(point['x']),'y':round(point['y'])}]
if action == 'door_toggle':
if data.get('ship','player') != 'player':
raise ValueError('Only doors on the player ship are controllable')
door=next((d for d in (state.get('player') or {}).get('doors',[])
if int(d['id'])==int(data['door_id'])),None)
if not door or not door.get('controllable',False):
raise ValueError('Door is unavailable or locked in the native game')
origin=state['player_origin']
return [{'type':'mouse','button':'left','phase':'click',
'x':round(origin['x']+door['x']),'y':round(origin['y']+door['y'])}]
if action == 'navigate':
screen = data['screen']
if screen in ('jump','ship','store') and not state.get('navigation',{}).get(screen,False):
raise ValueError('Navigation action is unavailable in the current game state')
if screen == 'tactical':
return [{'type':'key','key':289}]
if screen == 'menu':
return [{'type':'key','key':27}]
if screen == 'jump':
point = state['jump_button']
return [{'type':'mouse','button':'left','x':int(point['x'])+25,'y':int(point['y'])+15}]
if screen == 'ship':
point = state['ship_button']
return [{'type':'mouse','button':'left','x':int(point['x'])+15,'y':int(point['y'])+15}]
if screen == 'store':
return [{'type':'key','key':291}]
raise ValueError(f'Unsupported command: {action}')
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--lab', type=Path, required=True)
parser.add_argument('--project', type=Path, default=Path(__file__).resolve().parents[1])
parser.add_argument('--hooks', type=Path, required=True)
parser.add_argument('--seconds', type=float, default=0, help='Bounded diagnostic run; zero keeps bridge running')
parser.add_argument('--pid', type=int, help='Attach only to this verified lab PID instead of launching')
parser.add_argument('--allow-input', action='store_true', help='Enable commands from the VR client')
parser.add_argument('--stop-file',type=Path,help='Stop when this launcher-owned file appears')
args = parser.parse_args()
lab = args.lab.resolve()
marker = json.loads((lab / 'ftlvr-lab.json').read_text())
if not marker.get('activated'):
raise ValueError('Lab is prepared but not activated')
hooks = json.loads(args.hooks.read_text())
executable = lab / 'FTLGame.exe'
if hashlib.sha1(executable.read_bytes()).hexdigest() != hooks['sha1']:
raise ValueError('Lab executable fingerprint changed; refusing to attach')
local = args.project.resolve() / 'local_game_data'
local.mkdir(exist_ok=True)
status_path = local / 'bridge_status.json'
command_path = local / 'commands.jsonl'
frame_queue = queue.Queue(maxsize=1)
hud_queue = queue.Queue(maxsize=1)
log_queue = queue.Queue()
stop = threading.Event()
current = {}
native_text_entry={'active':False}
native_power_buttons={}
power_buttons_received=0.0
capture_size = [1280,720]
state_lock = threading.Lock()
errors = []
assets = Pkg(Path(marker['source_game'])/'ftl.dat')
prepared_ships = set()
recent_events = deque(maxlen=128)
bridge_session = uuid.uuid4().hex
publisher = FramePublisher(local)
(local/'.gdignore').touch()
def on_message(message, binary):
if message['type'] == 'error':
errors.append(message.get('stack', str(message)))
return
payload = message.get('payload', {})
if payload.get('type') in ('frame','hud_frame') and binary:
if payload['type']=='frame':
capture_size[:] = [payload.get('native_width',payload['width']),
payload.get('native_height',payload['height'])]
target=hud_queue if payload['type']=='hud_frame' else frame_queue
# Each stream keeps its newest sample. A regular screen cannot
# displace the supplemental HUD captured immediately before it.
try: target.put_nowait((payload,binary))
except queue.Full:
try: target.get_nowait()
except queue.Empty: pass
try: target.put_nowait((payload,binary))
except queue.Full: pass
else:
log_queue.put(payload)
def encode_frames(source_queue):
last_hud_image = None
while not stop.is_set():
try: payload,binary = source_queue.get(timeout=0.05)
except queue.Empty: continue
try:
image = Image.frombytes('RGBA',(payload['width'],payload['height']),binary).transpose(Image.Transpose.FLIP_TOP_BOTTOM)
captured_at=payload.get('captured_at',time.time())
with state_lock:
snapshot=dict(current)
# The Tactical stream retains the complete native screen at a
# smaller resolution. Its independent worker never waits for
# HUD flood-fill or PNG compression.
tactical_only=bool(snapshot.get('tactical') and not any(snapshot.get(key) for key in ('map_open','event_open','panel_open','blocking_ui')))
if payload['type']=='frame' and tactical_only and payload.get('supplemental_hud'):
publisher.publish('screen_frame',image,captured_at)
continue
if image.size != (1280,720): image = image.resize((1280,720),Image.Resampling.NEAREST)
# Preserve the original UI RGB. Make only the cleared black world transparent.
rgb = image.convert('RGB')
import numpy as np
pixels = np.array(image)
clean_pixels=None
panel_pixels=None
if payload['type']=='hud_frame':
if full_screen_capture(snapshot): continue
last_hud_image=Image.fromarray(remove_enemy_hud(clear_world_alpha(pixels)))
publisher.publish('hud_frame',last_hud_image,captured_at)
with state_lock: current['capture_full_screen']=False
continue
controller_screen=bool(snapshot.get('map_open') or snapshot.get('tactical'))
event_open=bool(snapshot.get('event_open'))
panel_open=bool(snapshot.get('panel_open'))
full_screen=full_screen_capture(snapshot)
# Native windows stay original. Gameplay never promotes a colored
# world/jump frame into an opaque head-locked screen.
if controller_screen:
publisher.publish('screen_frame',image,captured_at)
if event_open:
publisher.publish('event_frame',rgb.convert('RGBA'),captured_at)
if panel_open:
panel_pixels=native_window_frame(pixels)
publisher.publish('panel_frame',Image.fromarray(panel_pixels),captured_at)
# This original frame still supplies map/dialog/window pixels.
# Its independent HUD sample is processed by its own worker.
if payload.get('supplemental_hud') and not full_screen:
continue
if full_screen:
image=rgb.convert('RGBA')
elif controller_screen or event_open or panel_open or snapshot.get('transition'):
if last_hud_image is None:
if panel_open:
clean_pixels=clear_world_alpha(pixels)
hud=clean_pixels.copy()
hud[panel_pixels[:,:,3]>0,3]=0
last_hud_image=Image.fromarray(remove_enemy_hud(hud))
elif event_open:
pixels=clear_world_alpha(pixels)
left=313 if (snapshot.get('dialog') or {}).get('centered',True) else 163
pixels[138:528,left:left+650,3]=0
last_hud_image=Image.fromarray(remove_enemy_hud(pixels))
else:
last_hud_image=Image.new('RGBA',(1280,720),(0,0,0,0))
image=last_hud_image.copy()
else:
# A transition can race a snapshot by one frame. Retain the
# last UI until the native world is clipped; never flash it.
middle=np.any(pixels[180:490,400:850,:3]!=0,axis=2)
if float(middle.mean())>0.18:
if last_hud_image is None: continue
image=last_hud_image.copy()
else:
pixels=clear_world_alpha(pixels)
image=Image.fromarray(remove_enemy_hud(pixels))
last_hud_image=image.copy()
if not full_screen:
image=Image.fromarray(remove_pause_overlay(remove_enemy_hud(np.array(image))))
with state_lock:
current['capture_full_screen']=full_screen
publisher.publish('hud_frame',image,captured_at)
except Exception as error:
errors.append('Frame encode: '+str(error))
workers=[threading.Thread(target=encode_frames,args=(source,),daemon=True)
for source in (frame_queue,hud_queue)]
for worker in workers: worker.start()
process = None
if args.pid is None:
startup = subprocess.STARTUPINFO()
startup.dwFlags |= subprocess.STARTF_USESHOWWINDOW
startup.wShowWindow = 0
process = subprocess.Popen([str(executable),'--force-opengl'],cwd=lab,
startupinfo=startup,creationflags=subprocess.CREATE_NO_WINDOW)
pid = process.pid
else:
pid = args.pid
# Verify the attached process is this lab executable, not a different FTL copy.
import ctypes
from ctypes import wintypes
kernel = ctypes.WinDLL('kernel32',use_last_error=True)
kernel.OpenProcess.argtypes=[wintypes.DWORD,wintypes.BOOL,wintypes.DWORD]
kernel.OpenProcess.restype=wintypes.HANDLE
kernel.QueryFullProcessImageNameW.argtypes=[wintypes.HANDLE,wintypes.DWORD,wintypes.LPWSTR,ctypes.POINTER(wintypes.DWORD)]
kernel.CloseHandle.argtypes=[wintypes.HANDLE]
handle=kernel.OpenProcess(0x1000,False,pid)
buffer=ctypes.create_unicode_buffer(32768); count=wintypes.DWORD(len(buffer))
try:
if not handle or not kernel.QueryFullProcessImageNameW(handle,0,buffer,ctypes.byref(count)) or Path(buffer.value).resolve()!=executable:
raise ValueError('PID does not belong to the verified isolated lab')
finally:
if handle: kernel.CloseHandle(handle)
print('FTLVR_LAB_PID',pid,flush=True)
session=None
start=time.monotonic()
command_offset=command_path.stat().st_size if command_path.exists() else 0
log_offset=0
native_capture_status={}
capture_status_received=0.0
supplemental_enabled=None
pending_log=''
try:
time.sleep(1)
session=frida.attach(pid)
agent_source=(Path(__file__).parents[1]/'bridge/agent.js').read_text()
hooks['capture_interval_ms']=100
script=session.create_script(agent_source.replace('CONFIG_PLACEHOLDER',json.dumps(hooks)))
script.on('message',on_message)
script.load()
if process:
# Install native hooks after Hyperspace has finished its own detours.
boot_deadline = time.monotonic()+45
while time.monotonic()<boot_deadline:
boot_log=lab/'FTL.log'
if boot_log.exists() and 'Running Game!' in boot_log.read_text(errors='replace'):
break
if process.poll() is not None: raise RuntimeError('Lab exited during startup')
time.sleep(0.1)
else: raise TimeoutError('FTL did not finish startup')
script.exports_sync.start()
while not stop.is_set() and (not args.seconds or time.monotonic()-start<args.seconds):
if args.stop_file and args.stop_file.exists(): break
if process and process.poll() is not None: break
log_path=lab/'FTL_HS.log'
if not log_path.exists(): log_path=lab/'ftl_hs.log'
if log_path.exists():
if log_path.stat().st_size < log_offset:
log_offset=0; pending_log=''; recent_events.clear()
with log_path.open('r',encoding='utf-8',errors='replace') as stream:
stream.seek(log_offset)
pending_log+=stream.read()
log_offset=stream.tell()
lines=pending_log.split('\n'); pending_log=lines.pop()
latest=None; shots=[]
for line in lines:
try: state=decode_state_line(line)
except (ValueError,json.JSONDecodeError) as error: errors.append(str(error)); continue
if state:
latest=state; shots.extend(state.get('shots',[]))
elif 'FTLVR_ERROR' in line: errors.append(line.strip())
if latest:
enable_supplemental=bool(latest.get('ready') and latest.get('ui_mode')!='menu')
presentation='tactical' if latest.get('tactical') and not any(latest.get(key) for key in ('map_open','event_open','panel_open','blocking_ui')) else ('game' if enable_supplemental and not any(latest.get(key) for key in ('map_open','event_open','panel_open')) else 'window')
capture_mode=(enable_supplemental,presentation)
if supplemental_enabled!=capture_mode:
script.exports_sync.capturemode({'enabled':enable_supplemental,'presentation':presentation})
supplemental_enabled=capture_mode
if time.monotonic()-power_buttons_received<=1:
for row in (latest.get('player') or {}).get('system_status',[]):
point=native_power_buttons.get(str(int(row['id'])))
if point: row['power_button']=dict(point)
recent_events.extend(shots)
for side in ('player','enemy'):
ship=latest.get(side)
if ship:
layout, image_name=ship['layout'],ship['image']
key=layout if layout==image_name else f'{layout}__{image_name}'
ship['asset_key']=key
if key not in prepared_ships:
build_ship(assets,layout,local,image_name,native_image_rect=ship.get('ship_image'))
prepared_ships.add(key)
with state_lock:
latest['capture_full_screen']=current.get('capture_full_screen',False)
current.clear(); current.update(latest); current['shots']=list(recent_events)
current['text_entry']=dict(native_text_entry)
current['bridge_session']=bridge_session
current['bridge_time_unix']=time.time()
atomic_json(local/'live_state.json',current)
while not log_queue.empty():
payload=log_queue.get()
if payload.get('type')=='text_entry':
native_text_entry=dict(payload['value'])
with state_lock:
current['text_entry']=dict(native_text_entry)
continue
if payload.get('type')=='system_power_buttons':
width,height=payload['width'],payload['height']
native_power_buttons={key:{'x':point['x']*1280/width,'y':point['y']*720/height}
for key,point in payload['buttons'].items()
if 0<=point['x']<width and 0<=point['y']<height}
power_buttons_received=time.monotonic()
continue
print('FTLVR_AGENT',json.dumps(payload),flush=True)
if payload.get('type','').endswith('error'): errors.append(str(payload))
if args.allow_input and command_path.exists():
with command_path.open('r',encoding='utf-8') as stream:
stream.seek(command_offset)
for line in stream:
try:
command=json.loads(line)
if not -1 <= time.time()-float(command.get('time_unix',0)) <= 3: continue
with state_lock: instructions=translate_command(command,current)
for instruction in instructions:
instruction['id']=command.get('id',time.time_ns())
if 'x' in instruction:
instruction['x']=round(instruction['x']*capture_size[0]/1280)
instruction['y']=round(instruction['y']*capture_size[1]/720)
script.exports_sync.command(instruction)
except Exception as error:
errors.append('Command rejected: '+str(error))
command_offset=stream.tell()
if time.monotonic()-capture_status_received>=1:
native_capture_status=script.exports_sync.status()
capture_status_received=time.monotonic()
atomic_json(status_path,{'pid':pid,'connected':True,'input_enabled':args.allow_input,
'live_state_received':bool(current) and time.time()-current.get('bridge_time_unix',0)<2,
'capture_received':any((local/name).exists() and time.time()-(local/name).stat().st_mtime<2 for name in ('hud_frame.rgba','hud_frame.png')),
'errors':errors[-10:],'heartbeat':time.time(),'native_capture':native_capture_status})
time.sleep(0.02)
finally:
stop.set()
for worker in workers: worker.join(timeout=2)
publisher.close()
if process and process.poll() is None:
try:
script.exports_sync.command({'type':'key','key':290,'id':'save-and-exit'})
process.wait(timeout=8)
except Exception:
process.terminate(); process.wait(timeout=10)
errors.append('Graceful exit failed; last FTL autosave remains available')
if session:
try: session.detach()
except frida.InvalidOperationError: pass
atomic_json(status_path,{'pid':pid,'connected':False,'errors':errors[-10:],'heartbeat':time.time()})
if errors: print('FTLVR_ERRORS',json.dumps(errors[-10:]),flush=True)
if __name__=='__main__':
main()
+129
View File
@@ -0,0 +1,129 @@
"""Create this source checkout's Python environment, or check launch readiness.
Setup installs Python packages and creates local configuration only. It never
copies, patches, launches or attaches to FTL. --check and --dry-run are read-only.
"""
from __future__ import annotations
import argparse
import importlib.util
import json
import os
import shutil
import subprocess
import sys
from pathlib import Path
PROJECT = Path(__file__).resolve().parents[1]
VENV = PROJECT / ".venv"
VENV_PYTHON = VENV / "Scripts" / "python.exe"
LOCAL = PROJECT / "local_game_data"
CONFIG = LOCAL / "launcher.json"
def require_platform() -> None:
if os.name != "nt":
raise ValueError("The live game bridge currently supports Windows only.")
if sys.version_info < (3, 10):
raise ValueError("Python 3.10 or newer is required; Python 3.12 is recommended.")
def setup(dry_run: bool = False) -> None:
require_platform()
steps = [
f"Create or reuse {VENV}",
"Install the packages from requirements.txt inside .venv (internet access required)",
"Copy config/launcher.example.json to local_game_data/launcher.json if absent",
"Keep all game paths unconfigured until you edit launcher.json",
]
for step in steps:
print(step, flush=True)
if dry_run:
print("Dry run complete: no files or dependencies were changed.")
return
if VENV.exists() and not VENV_PYTHON.is_file():
raise ValueError(".venv exists but is incomplete. Rename that folder and run SETUP.cmd again.")
if not VENV_PYTHON.is_file():
subprocess.run([sys.executable, "-m", "venv", str(VENV)], check=True)
subprocess.run(
[str(VENV_PYTHON), "-m", "pip", "install", "-r", str(PROJECT / "requirements.txt")],
check=True,
)
LOCAL.mkdir(exist_ok=True)
(LOCAL / ".gdignore").touch()
if not CONFIG.exists():
shutil.copyfile(PROJECT / "config" / "launcher.example.json", CONFIG)
print("\nPython environment ready. Finish docs/INSTALLATION.md before launching.")
print(f"Edit your local paths in: {CONFIG}")
print("SETUP.cmd does not install Hyperspace or prepare a game copy.")
def check(desktop: bool = False) -> None:
require_platform()
missing = [name for name in ("frida", "PIL", "numpy", "capstone")
if importlib.util.find_spec(name) is None]
if missing:
raise ValueError("Missing Python packages: " + ", ".join(missing) + ". Run SETUP.cmd first.")
if not CONFIG.is_file():
raise ValueError("Missing local_game_data/launcher.json. Run SETUP.cmd and edit its paths.")
config = json.loads(CONFIG.read_text(encoding="utf-8-sig"))
if not isinstance(config, dict):
raise ValueError("launcher.json must contain a JSON object with lab, hooks and godot paths.")
for key in ("lab", "hooks", "godot"):
if not isinstance(config.get(key), str) or not config[key].strip():
raise ValueError(f"launcher.json needs a nonempty {key} path.")
if not Path(config[key]).is_absolute():
raise ValueError(f"Use an absolute path for {key} in launcher.json.")
lab = Path(config["lab"])
if not lab.is_dir():
raise ValueError("The configured lab directory does not exist. Complete isolated lab preparation.")
for key in ("hooks", "godot"):
if not Path(config[key]).is_file():
raise ValueError(f"The configured {key} file does not exist: {config[key]}")
if not (lab / "ftlvr-lab.json").is_file():
raise ValueError("The configured lab has no ftlvr-lab.json marker. Complete isolated lab preparation.")
marker = json.loads((lab / "ftlvr-lab.json").read_text(encoding="utf-8-sig"))
if not isinstance(marker, dict):
raise ValueError("The lab marker is malformed. Use a correctly prepared isolated lab.")
source = marker.get("source_game")
if not isinstance(source, str) or not (Path(source) / "ftl.dat").is_file():
raise ValueError("The owned original installation recorded by the lab is unavailable. Restore that path.")
if not (LOCAL / "manifest.json").is_file():
raise ValueError("Local game assets are missing. Run tools/extract_ftl.py on your owned ftl.dat.")
manifest = json.loads((LOCAL / "manifest.json").read_text(encoding="utf-8-sig"))
if not isinstance(manifest, dict):
raise ValueError("The local asset manifest is malformed. Run tools/extract_ftl.py again.")
if not manifest.get("ships") or not manifest.get("fonts") or not manifest.get("ui_assets"):
raise ValueError("Local extraction is incomplete. Run tools/extract_ftl.py again.")
# The production launcher's own checks include executable fingerprints,
# required hook families, dependencies, loader and the OpenXR runtime.
sys.dont_write_bytecode = True
from launch import preflight
result = preflight(config, desktop=desktop)
print(json.dumps(result, indent=2))
print("Setup checks passed. No game was launched or modified.")
print("This checks readiness; it does not measure headset performance.")
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
modes = parser.add_mutually_exclusive_group()
modes.add_argument("--check", action="store_true", help="Read-only local launch preflight")
modes.add_argument("--dry-run", action="store_true", help="Describe setup without writing or installing")
parser.add_argument("--desktop", action="store_true", help="Skip the SteamVR runtime check with --check")
args = parser.parse_args()
if args.desktop and not args.check:
parser.error("--desktop is only valid with --check")
if args.check:
check(desktop=args.desktop)
else:
setup(dry_run=args.dry_run)
if __name__ == "__main__":
try:
main()
except (OSError, ValueError, KeyError, subprocess.CalledProcessError) as error:
print(f"FTL Tabletop VR setup: {error}", file=sys.stderr)
sys.exit(1)
+271
View File
@@ -0,0 +1,271 @@
import json
import tempfile
import time
import unittest
from pathlib import Path
from PIL import Image
import numpy as np
from hud_alpha import clear_world_alpha
from run_bridge import (decode_state_line, translate_command, full_screen_capture,
remove_enemy_hud, native_window_frame, remove_pause_overlay,
FRAME_HEADER, raw_frame_bytes, FramePublisher)
class BridgeTests(unittest.TestCase):
def test_raw_transport_keeps_dimensions_rgba_orientation_and_timestamp(self):
image=Image.new('RGBA',(960,540),(10,20,30,40))
image.putpixel((0,0),(1,2,3,4));image.putpixel((0,539),(5,6,7,8))
raw=raw_frame_bytes(image,12,123.5)
self.assertEqual(FRAME_HEADER.size,36)
self.assertEqual(FRAME_HEADER.unpack(raw[:36]),(b'FVR1',1,960,540,4,12,123.5))
self.assertEqual(len(raw),36+960*540*4)
self.assertEqual(raw[36:40],bytes((1,2,3,4)))
self.assertEqual(raw[36+539*960*4:40+539*960*4],bytes((5,6,7,8)))
def test_independent_raw_streams_publish_without_waiting_for_png_preview(self):
with tempfile.TemporaryDirectory() as directory:
publisher=FramePublisher(Path(directory))
image=Image.new('RGBA',(16,9),(10,20,30,40))
try:
for i in range(8):publisher.publish('screen_frame',image,float(i))
publisher.publish('hud_frame',image,8.)
raw=(Path(directory)/'screen_frame.rgba').read_bytes()
self.assertEqual(FRAME_HEADER.unpack(raw[:36])[-2:],(8,7.))
self.assertTrue((Path(directory)/'hud_frame.rgba').exists())
deadline=time.monotonic()+1
while not (Path(directory)/'hud_frame.png').exists() and time.monotonic()<deadline:time.sleep(.01)
self.assertTrue((Path(directory)/'hud_frame.png').exists())
finally:publisher.close()
def test_jump_and_pending_events_never_become_full_screen(self):
base={'ready':True,'ui_mode':'screen'}
self.assertFalse(full_screen_capture(base))
for key in ('transition','event_pending','event_open','map_open','tactical'):
self.assertFalse(full_screen_capture(dict(base,**{key:True})),key)
self.assertTrue(full_screen_capture({'ready':False,'ui_mode':'menu'}))
self.assertTrue(full_screen_capture({'ready':True,'ui_mode':'menu'}))
self.assertFalse(full_screen_capture(dict(base,panel_open=True,panel_kind='shop')))
def test_enemy_frame_removed_without_touching_player_widgets(self):
pixels=np.full((720,1280,4),255,dtype=np.uint8)
result=remove_enemy_hud(pixels)
self.assertEqual(result[48,879,3],0)
self.assertEqual(result[588,1273,3],0)
self.assertEqual(result[590,1279,3],0,'Right-edge target separator must never survive')
self.assertEqual(result[605,1150,3],255,'Player subsystem strip must remain')
self.assertEqual(result[100,70,3],255)
self.assertTrue(np.array_equal(result[:,:,:3],pixels[:,:,:3]))
def test_window_frame_keeps_native_black_ink_and_discards_hud(self):
pixels=np.zeros((720,1280,4),dtype=np.uint8)
pixels[120:610,300:980,:3]=[120,180,190]
pixels[135:595,315:965,:3]=0
pixels[40:75,20:280,:3]=[100,200,180]
result=native_window_frame(pixels)
self.assertEqual(result[360,640,3],255)
self.assertEqual(result[120,300,3],255)
self.assertEqual(result[50,70,3],0)
def test_sell_drop_box_and_item_tooltip_are_retained(self):
pixels=np.zeros((720,1280,4),dtype=np.uint8)
pixels[100:600,340:940,:3]=[160,190,185]
pixels[115:585,355:925,:3]=0
pixels[190:385,65:325,:3]=[160,190,185]
pixels[202:372,77:313,:3]=0
pixels[160:280,945:1265,:3]=[160,190,185]
pixels[172:268,957:1253,:3]=0
pixels[155:190,5:95,:3]=[20,36,30]
pixels[610:710,240:650,:3]=[100,180,150]
result=native_window_frame(pixels)
self.assertEqual(result[300,195,3],255,'Native SELL drop target must remain visible')
self.assertEqual(result[200,1100,3],255,'Native item tooltip must remain visible')
self.assertEqual(result[200,50,3],0,'Separate crew HUD must be removed')
self.assertEqual(result[640,400,3],0,'Separate weapon HUD must be removed')
self.assertEqual(result[650,640,3],0)
self.assertTrue(np.array_equal(result[:,:,:3],pixels[:,:,:3]))
pixels[120:610,300:980,:3]=0
self.assertFalse(np.any(native_window_frame(pixels)[:,:,3]))
def test_sell_window_survives_transparent_center_and_separate_content(self):
pixels=np.zeros((720,1280,4),dtype=np.uint8)
pixels[120:126,300:980,:3]=[120,170,175]
pixels[120:610,300:306,:3]=[120,170,175]
pixels[120:610,974:980,:3]=[120,170,175]
pixels[604:610,300:600,:3]=[120,170,175]
pixels[420:450,600:700,:3]=[220,200,180]
pixels[30:75,20:280,:3]=[100,200,180]
result=native_window_frame(pixels)
self.assertEqual(result[360,640,3],0)
self.assertEqual(result[120,300,3],255)
self.assertEqual(result[430,650,3],255)
self.assertEqual(result[50,70,3],0)
def test_cached_native_pause_glyph_is_removed_without_cutting_controls(self):
pixels=np.full((720,1280,4),255,dtype=np.uint8)
result=remove_pause_overlay(pixels)
self.assertEqual(result[552,640,3],0)
self.assertEqual(result[620,640,3],255)
self.assertEqual(result[400,70,3],255)
def test_panel_open_gap_does_not_retain_dim_native_pause_glyph(self):
pixels=np.zeros((720,1280,4),dtype=np.uint8)
pixels[100:550,340:930,:3]=[170,210,200]
pixels[115:535,355:915,:3]=0
pixels[540:598,790:915,:3]=[170,210,200]
pixels[553:585,803:902,:3]=0
pixels[560:577,550:730,:3]=[26,39,42]
result=native_window_frame(pixels)
self.assertFalse(np.any(result[560:577,550:730,3]))
self.assertEqual(result[570,850,3],255,'Native close button ink must remain')
def test_native_text_entry_requires_active_context_and_uses_text_events(self):
state={'text_entry':{'active':True,'id':'entry-3','text':'Kestrel','max_length':16}}
command={'action':'text_input','data':{'entry_id':'entry-3','op':'insert','text':'A é'}}
result=translate_command(command,state)
self.assertEqual([x['codepoint'] for x in result],[65,32,233])
self.assertTrue(all(x['type']=='text' and x['entry_id']=='entry-3' for x in result))
for op,value in [('backspace',3),('confirm',0)]:
command['data']={'entry_id':'entry-3','op':op}
self.assertEqual(translate_command(command,state)[0]['event'],value)
command['data']={'entry_id':'entry-3','op':'cancel'}
self.assertEqual(translate_command(command,state)[0]['type'],'text_cancel')
command['data']['entry_id']='entry-2'
with self.assertRaises(ValueError):translate_command(command,state)
for entry_id in (None, ''):
command['data']['entry_id']=entry_id
with self.assertRaises(ValueError):translate_command(command,state)
command['data'].pop('entry_id')
with self.assertRaises(ValueError):translate_command(command,state)
state['text_entry']['active']=False
command['data']['entry_id']='entry-3'
with self.assertRaises(ValueError):translate_command(command,state)
def test_door_uses_native_center_and_rejects_locked_or_enemy_door(self):
state={'ready':True,'ui_mode':'game','player_origin':{'x':380,'y':116},
'player':{'doors':[{'id':4,'x':175,'y':192,'controllable':True}]}}
command={'action':'door_toggle','data':{'door_id':4,'ship':'player'}}
result=translate_command(command,state)
self.assertEqual((result[0]['x'],result[0]['y']),(555,308))
self.assertEqual(result[0]['phase'],'click')
state['player']['doors'][0]['controllable']=False
with self.assertRaises(ValueError): translate_command(command,state)
command['data']['ship']='enemy'
with self.assertRaises(ValueError): translate_command(command,state)
def test_drone_render_space_is_independent_of_owning_ship(self):
raw={'protocol':2,'source':'hyperspace',
'player':{'drones':[{'id':'9','owner':0,'space':1,'is_space':True}]},
'enemy':{'drones':{}}}
state=decode_state_line('FTLVR_STATE '+json.dumps(raw))
self.assertEqual(state['player']['drones'],[])
self.assertEqual(state['enemy']['drones'][0]['owner'],0)
self.assertEqual(state['player']['doors'],[])
def test_event_wheel_rejects_stale_and_disabled_choices(self):
state={'ready':True,'ui_mode':'game','event_open':True,
'dialog':{'id':'7','choices':[{'enabled':True},{'enabled':False}]}}
command={'action':'event_choice','data':{'index':0,'dialog_id':'7'}}
self.assertEqual(translate_command(command,state)[0]['key'],49)
command['data']['dialog_id']='6'
with self.assertRaises(ValueError): translate_command(command,state)
command['data'].update(dialog_id='7',index=1)
with self.assertRaises(ValueError): translate_command(command,state)
with self.assertRaises(ValueError): translate_command({'action':'select_crew','data':{'crew_id':'crew-1'}},state)
def test_modifiers_reach_every_input_phase(self):
commands=translate_command({'action':'ui_mouse','data':{'x':70,'y':30,'phase':'down',
'modifiers':{'shift':True,'control':True}}},{})
self.assertEqual(commands[0]['modifiers'],{'shift':True,'control':True})
self.assertEqual(translate_command({'action':'key','data':{'key':49}}, {})[0]['modifiers'],
{'shift':False,'control':False})
with self.assertRaises(ValueError):
translate_command({'action':'key','data':{'key':49,'modifiers':{'alt':True}}},{})
def test_unavailable_navigation_is_rejected(self):
state={'ready':True,'ui_mode':'game','navigation':{'jump':True,'store':False},
'jump_button':{'x':530,'y':20}}
with self.assertRaises(ValueError): translate_command({'action':'navigate','data':{'screen':'store'}},state)
self.assertEqual(translate_command({'action':'navigate','data':{'screen':'jump'}},state)[0]['x'],555)
def test_alpha_preserves_enclosed_black_ui_ink_and_rgb(self):
pixels=np.zeros((20,30,4),dtype=np.uint8)
pixels[4:16,6:24,:3]=[125,180,190]
pixels[7:12,10:16,:3]=0
result=clear_world_alpha(pixels)
self.assertEqual(result[0,0,3],0)
self.assertEqual(result[9,12,3],255)
self.assertTrue(np.array_equal(result[:,:,:3],pixels[:,:,:3]))
def test_empty_lua_vectors_become_arrays(self):
state=decode_state_line('FTLVR_STATE '+json.dumps({'protocol':2,'source':'hyperspace',
'shots':{},'projectiles':{},'player':{'rooms':{},'crew':{},'weapons':{}}}))
self.assertEqual(state['player']['crew'],[])
self.assertEqual(state['shots'],[])
def test_native_room_target_includes_enemy_screen_offset(self):
state={'ready':True,'ui_mode':'game','combat':True,'weapon_selected':1,
'enemy_origin':{'x':953,'y':105},
'enemy':{'rooms':[{'id':3,'center':{'x':70,'y':192}}]}}
result=translate_command({'action':'target_room','data':{'room_id':3}},state)
self.assertEqual((result[0]['x'],result[0]['y']),(1023,297))
state['weapon_selected']=-1
with self.assertRaises(ValueError): translate_command({'action':'target_room','data':{'room_id':3}},state)
def test_targeted_systems_and_tactical_use_native_room_inputs(self):
state={'ready':True,'ui_mode':'screen','tactical':True,'combat':True,'weapon_selected':-1,
'enemy_origin':{'x':950,'y':105},'player_origin':{'x':380,'y':115},
'enemy':{'rooms':[{'id':3,'center':{'x':70,'y':192}}]},
'player':{'rooms':[{'id':5,'center':{'x':175,'y':157}}]}}
command={'action':'target_room','data':{'room_id':3,'ship':'enemy'}}
for kind in ('mind','hacking','teleporter','weapon'):
state['targeting']={'active':True,'kind':kind,'ships':['enemy']}
result=translate_command(command,state)
self.assertEqual((result[0]['x'],result[0]['y']),(1020,297))
self.assertEqual(result[0]['type'],'mouse')
state['targeting']={'active':True,'kind':'mind','ships':['player','enemy']}
command['data']={'room_id':5,'ship':'player'}
self.assertEqual(translate_command(command,state)[0]['x'],555)
state['blocking_ui']=True
with self.assertRaises(ValueError):translate_command(command,state)
state['blocking_ui']=False;state['map_open']=True
with self.assertRaises(ValueError):translate_command(command,state)
state['map_open']=False;state['targeting']['active']=False
with self.assertRaises(ValueError):translate_command(command,state)
def test_system_power_uses_native_icons_and_never_forces_modifiers(self):
state={'ready':True,'ui_mode':'screen','tactical':True,
'player':{'system_status':[{'id':0,'key':'shields','powerable':True,
'power_button':{'x':90,'y':684}},{'id':8,'key':'doors','powerable':False}]}}
command={'action':'system_power','data':{'system_id':0,'direction':1,
'modifiers':{'shift':True,'control':True}}}
result=translate_command(command,state)[0]
self.assertEqual((result['x'],result['y'],result['button']),(90,684,'left'))
self.assertEqual(result['modifiers'],{'shift':False,'control':False})
command['data']={'system_key':'shields','direction':-1}
self.assertEqual(translate_command(command,state)[0]['button'],'right')
for data in ({'system_id':8,'direction':1},{'system_id':0,'direction':0}):
command['data']=data
with self.assertRaises(ValueError):translate_command(command,state)
state['player']['system_status'][0].pop('power_button')
command['data']={'system_id':0,'direction':1}
with self.assertRaises(ValueError):translate_command(command,state)
def test_ui_bounds_and_hotkey_whitelist(self):
with self.assertRaises(ValueError): translate_command({'action':'ui_mouse','data':{'x':1280,'y':3}}, {})
with self.assertRaises(ValueError): translate_command({'action':'key','data':{'key':9999}}, {})
def test_boarding_crew_uses_current_ship_not_ownership(self):
raw={'protocol':2,'source':'hyperspace','ready':True,'ui_mode':'game',
'player':{'crew':[{'id':'crew-1','owner':0,'ship':1,'controllable':True,'x':70,'y':175}]},
'enemy':{'rooms':[{'id':3,'center':{'x':70,'y':192}}]},
'enemy_origin':{'x':953,'y':105}}
state=decode_state_line('FTLVR_STATE '+json.dumps(raw))
self.assertEqual(state['player']['crew'],[])
self.assertEqual(state['enemy']['crew'][0]['owner'],0)
commands=translate_command({'action':'move_crew','data':{'crew_id':'crew-1','room_id':3}},state)
self.assertEqual((commands[-1]['x'],commands[-1]['y']),(1023,297))
self.assertEqual(commands[-1]['button'],'right')
if __name__=='__main__': unittest.main()
+164
View File
@@ -0,0 +1,164 @@
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 _run() -> void:
var scene: Node3D = load("res://main.tscn").instantiate()
root.add_child(scene)
await process_frame
scene.set_process(false)
var player: Node3D = scene.player_ship
var enemy: Node3D = scene.enemy_ship
var effects: Node3D = scene.combat_effects
player.set_process(false)
enemy.set_process(false)
effects.set_process(false)
_check(player.global_basis.x.normalized().dot(Vector3.RIGHT) > 0.99, "Player bow must face enemy")
_check((-enemy.global_basis.z).normalized().dot(Vector3.LEFT) > 0.99, "Enemy bow must face player")
_check(scene.hud_surface.get_parent() == scene.camera, "HUD must follow the tracked headset")
for ship in [player, enemy]:
_check(ship.weapon_origin(0).is_equal_approx(ship.to_global(ship.mount_points[0])), "Mount must use the ship world transform")
_check(ship.room_target(0).is_equal_approx(ship.to_global(ship.room_points[0])), "Target room must use the ship world transform")
ship.set_shields(0)
_check(not ship.shield_shell.visible, "Empty shields must disappear")
ship.set_shields(0, 5)
_check(ship.shield_shell.visible, "Super shields must remain visible")
ship.set_shields(2)
var from: Vector3 = player.weapon_origin(0)
var target: Vector3 = enemy.room_target(0)
var hit: Vector3 = enemy.shield_intercept(from, target)
var normalized: Vector3 = (enemy.to_local(hit) - enemy.shield_center) / enemy.shield_radii
_check(absf(normalized.length() - 1.0) < 0.001, "Shield hit must lie on the rotated ellipsoid")
_check(from.distance_to(hit) < from.distance_to(target), "Shield must intercept before the target room")
for source in ["player", "enemy"]:
var sender: Node3D = player if source == "player" else enemy
var receiver: Node3D = enemy if source == "player" else player
scene._present_shot({"source": source, "kind": "laser", "target_room": 0, "outcome": "hull", "duration": 1.0})
var shot: Dictionary = effects.shots.back()
_check(Vector3(shot["start"]).is_equal_approx(sender.weapon_origin(0)), "Shot must originate at sender weapon")
_check(Vector3(shot["end"]).is_equal_approx(receiver.room_target(0)), "Shot must terminate at receiver room")
var node: MeshInstance3D = shot["node"]
effects._process(0.5)
_check(node.global_position.is_equal_approx(Vector3(shot["start"]).lerp(shot["end"], 0.5)), "Projectile must travel between ships")
effects.simulation_paused = true
var paused_position := node.global_position
effects._process(0.4)
_check(node.global_position.is_equal_approx(paused_position), "Pause must freeze projectiles")
effects.simulation_paused = false
effects._process(0.6)
_check(effects.shots.is_empty(), "Finished projectile must be removed")
_check(not effects.impacts.is_empty(), "Hull hit must produce an impact")
effects.simulation_paused = true
var flash: MeshInstance3D = effects.impacts.back()["node"]
var flash_scale := flash.scale
effects._process(0.4)
_check(flash.scale == flash_scale, "Pause must freeze impact flashes")
effects.simulation_paused = false
effects._process(0.3)
var impact_count: int = effects.impacts.size()
scene._present_shot({"source": "player", "kind": "laser", "outcome": "miss", "duration": 0.1})
effects._process(0.2)
_check(effects.impacts.size() == impact_count, "Miss must not produce a hull impact")
scene._present_shot({"source": "enemy", "kind": "bomb", "outcome": "hull", "duration": 1.0})
_check(not effects.shots.back()["node"].visible, "Bomb must teleport rather than fly through space")
effects._process(1.1)
scene._present_shot({"source": "player", "kind": "beam", "outcome": "shield", "target_room": 0, "end_room": 4, "duration": 1.0})
effects._process(0.5)
var beam: Dictionary = effects.shots.back()
var mesh: ImmediateMesh = beam["node"].mesh
var vertices: PackedVector3Array = mesh.surface_get_arrays(0)[Mesh.ARRAY_VERTEX]
_check(vertices[0].is_equal_approx(effects.to_local(player.weapon_origin(0))), "Beam must connect to sender mount")
var core: MeshInstance3D = beam["node"].get_node("BeamCore")
var swept_endpoint: Vector3 = Vector3(beam["end"]).lerp(beam["beam_end"], 0.5)
_check(core.global_position.is_equal_approx((player.weapon_origin(0) + swept_endpoint) * 0.5), "Visible beam cylinder must follow its exact native sweep line")
_check(absf(core.global_basis.y.length() - player.weapon_origin(0).distance_to(swept_endpoint)) < 0.001, "Beam transform must preserve its emitter and target positions")
_check(core.mesh == beam["node"].get_node("BeamGlow").mesh and core.mesh.height == 1.0, "Beam layers must reuse immutable unit geometry")
var mesh_changes := {"count": 0}
core.mesh.changed.connect(func(): mesh_changes["count"] += 1)
effects._process(0.1)
_check(mesh_changes["count"] == 0, "A live sweep must not rebuild shared beam geometry")
var endpoint: Vector3 = beam["beam_end"]
_check(absf(((enemy.to_local(endpoint) - enemy.shield_center) / enemy.shield_radii).length() - 1.0) < 0.001, "Blocked beam must stay on shield")
effects._process(0.6)
_check(enemy.shield_flash > 0.0, "Shield impact must flash")
effects._process(0.3)
scene._present_shot({"source": "player", "kind": "laser", "outcome": "pending", "projectile_id": "live-miss", "target_room": 0})
effects.update_live_projectiles([{"id": "live-miss", "progress": 0.7, "missed": true}])
effects._process(0.1)
_check(Vector3(effects.shots.back()["end"]).distance_to(enemy.room_target(0)) > 0.4, "Native miss trajectory must survive endpoint updates")
effects.update_live_projectiles([])
effects._process(0.3)
effects.update_live_projectiles([])
scene._present_shot({"source": "player", "kind": "beam", "outcome": "pending", "projectile_id": "live-beam", "target_room": 0, "end_point": {"x": 100, "y": 180}})
var beam_point := {"x": 80, "y": 170}
effects.update_live_projectiles([{"id": "live-beam", "progress": 0.65, "beam_point": beam_point}])
effects._process(0.1)
var live_beam: Dictionary = effects.shots.back()
var live_mesh: ImmediateMesh = live_beam["node"].mesh
var live_vertices: PackedVector3Array = live_mesh.surface_get_arrays(0)[Mesh.ARRAY_VERTEX]
_check(live_vertices[1].is_equal_approx(effects.to_local(enemy.to_global(enemy.pixel_point(beam_point)))), "Live beam must follow FTL's current sweep position")
effects.resolve_live({"projectile_id": "live-beam", "outcome": "beam", "target": beam_point}, enemy)
_check(effects.shots.size() == 1, "First beam room hit must not delete a continuing sweep")
effects.update_live_projectiles([])
effects._process(0.4)
effects.update_live_projectiles([])
for kind in ["laser", "missile", "ion", "asteroid"]:
effects.spawn_shot({"source": "player", "kind": kind, "outcome": "hull", "duration": 1.0, "target_room": 0}, player, enemy)
var refined: Dictionary = effects.shots.back()
effects._process(0.5)
var refined_node: MeshInstance3D = refined["node"]
var direction: Vector3 = Vector3(refined["end"]) - Vector3(refined["start"])
_check((-refined_node.global_basis.z).normalized().dot(direction.normalized()) > 0.999, "Projectile model must point from sender toward receiver")
_check(refined_node.global_position.is_equal_approx(Vector3(refined["start"]).lerp(refined["end"], 0.5)), "Refined geometry must preserve authoritative travel timing")
effects._process(0.6)
# A combat drone can be owned by the player while physically orbiting the
# enemy. A defense drone can instead target a missile in its own space.
var drone_origin := {"x": 90, "y": 130}
var drone_target := {"x": 170, "y": 120}
effects.spawn_shot({"kind": "laser", "outcome": "pending", "projectile_id": "defense",
"origin_point": drone_origin, "origin_space": 1, "target": drone_target,
"target_space": 1}, player, enemy)
effects.update_live_projectiles([{"id": "defense", "progress": 0.4}])
effects._process(0.1)
var defense: Dictionary = effects.shots.back()
_check(Vector3(defense["start"]).is_equal_approx(enemy.to_global(enemy.pixel_point(drone_origin))), "Boarding drone fire must originate in its current native space")
_check(Vector3(defense["end"]).is_equal_approx(enemy.to_global(enemy.pixel_point(drone_target))), "Defense drone fire must target the actual native space and point")
# A paused native projectile remains frozen even beyond the presentation
# grace. A stale fire record must expire despite simulation time standing still.
effects.simulation_paused = true
var paused_native_node: MeshInstance3D = defense["node"]
var paused_native_position := paused_native_node.global_position
var paused_native_time: float = defense["time"]
var paused_impact_count: int = effects.impacts.size()
effects.spawn_shot({"kind": "laser", "outcome": "pending", "projectile_id": "paused-phantom"}, player, enemy)
effects.update_live_projectiles([{"id": "defense", "progress": 0.4}])
_check(effects.shots.size() == 2, "A newly received fire record must retain a short snapshot grace")
var grace_start_ms := Time.get_ticks_msec()
while Time.get_ticks_msec() - grace_start_ms < 360:
await process_frame
effects._process(1.0)
effects.update_live_projectiles([{"id": "defense", "progress": 0.4}])
_check(effects.shots.size() == 1 and effects.shots[0]["projectile_id"] == "defense", "Paused never-observed fire must expire using monotonic presentation time")
_check(paused_native_node.global_position == paused_native_position and defense["time"] == paused_native_time, "A genuinely active paused projectile must remain frozen beyond the grace")
effects.update_live_projectiles([])
_check(effects.shots.is_empty(), "A previously observed projectile must disappear immediately when native state removes it during pause")
_check(effects.impacts.size() == paused_impact_count, "Stale projectile cleanup must not invent a hit")
effects.spawn_shot({"kind": "beam", "outcome": "pending", "projectile_id": "paused-beam"}, player, enemy)
effects.update_live_projectiles([{"id": "paused-beam", "progress": 0.4}])
effects.resolve_live({"projectile_id": "paused-beam", "outcome": "beam", "target": beam_point}, enemy)
_check(effects.shots.size() == 1, "A paused beam tile hit must retain the native continuing sweep")
print("COMBAT_TESTS ", "PASS" if failures == 0 else "FAIL: %d" % failures)
scene.queue_free()
await process_frame
quit(0 if failures == 0 else 1)
+1
View File
@@ -0,0 +1 @@
uid://dtdhv1evsbxrk
+307
View File
@@ -0,0 +1,307 @@
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 wheel: Node3D = load("res://scripts/shortcut_wheel.gd").new()
root.add_child(wheel)
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()
+52
View File
@@ -0,0 +1,52 @@
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 _run() -> void:
var space: Node3D = load("res://scripts/space_environment.gd").new()
root.add_child(space)
space.build()
space.set_process(false)
_check(space.star_positions.size() == 1696, "Two star shells must cover the sky")
for axis in [Vector3.RIGHT, Vector3.LEFT, Vector3.UP, Vector3.DOWN, Vector3.FORWARD, Vector3.BACK]:
var count := 0
for point in space.star_positions:
if Vector3(point).normalized().dot(axis) > 0.8:
count += 1
_check(count > 100, "Every direction must contain stars, including overhead and behind")
var meshes := 0
for child in space.get_children():
if child is MultiMeshInstance3D:
meshes += 1
_check(meshes == 3, "Starfield must batch its geometry into three draw calls")
for kind in ["asteroid", "sun", "storm", "nebula", "pulsar", "clear"]:
space.apply_state({"hazard": kind, "paused": false})
_check(space.hazard == kind, "Environment must follow the native hazard")
_check(space.hazard_root.get_child_count() < 40, "Hazard geometry must stay bounded")
if kind != "clear":
_check(space.hazard_root.get_child_count() > 0, "Each active hazard must have a 3D presentation")
else:
_check(space.hazard_root.get_child_count() == 0, "Old hazard geometry must be removed at a clear beacon")
space.apply_state({"hazard": "asteroid", "paused": true})
var rock: Node3D = space.rocks[0]["node"]
var location := rock.position
space._process(0.5)
_check(rock.position == location, "Pause must freeze nearby asteroid motion")
space.apply_state({"hazard": "asteroid", "paused": false})
space._process(0.5)
_check(rock.position != location, "Unpaused asteroid field must have parallax motion")
print("ENVIRONMENT_TESTS ", "PASS" if failures == 0 else "FAIL: %d" % failures)
space.queue_free()
await process_frame
quit(0 if failures == 0 else 1)
+1
View File
@@ -0,0 +1 @@
uid://b2jy3xjno2on2
+80
View File
@@ -0,0 +1,80 @@
import struct
import tempfile
import unittest
from pathlib import Path
from extract_ftl import Pkg, build_ship, extract_fonts, parse_layout, system_rooms
class ExtractTests(unittest.TestCase):
def test_owned_font_bootstrap_copies_original_bytes_only(self):
original = b"FONT\x01" + bytes(19)
class FakePkg:
entries = {"fonts/JustinFont10.font": original}
def read(self, name):
return self.entries[name]
with tempfile.TemporaryDirectory() as folder:
target = Path(folder)
self.assertEqual(extract_fonts(FakePkg(), target), ["fonts/JustinFont10.font"])
self.assertEqual((target / "fonts/JustinFont10.font").read_bytes(), original)
self.assertEqual(list(target.rglob("*.ttf")), [])
FakePkg.entries["fonts/JustinFont10.font"] = b"bad font"
with self.assertRaises(ValueError):
extract_fonts(FakePkg(), target)
def test_layout_room_and_door(self):
layout = parse_layout(b"X_OFFSET\n0\nROOM\n7\n3\n4\n2\n1\nDOOR\n4\n4\n7\n8\n1\n")
self.assertEqual(layout["rooms"], [{"id": 7, "x": 3, "y": 4, "w": 2, "h": 1}])
self.assertEqual(layout["doors"][0]["b"], 8)
def test_pkg_bounds_and_read(self):
name = b"data/test.txt\x00"
payload = b"FTL test"
offset = 16 + 20 + len(name)
header = b"PKG\n" + struct.pack(">HHII", 16, 20, 1, len(name))
record = struct.pack(">I", 0) + b"\x00\x00\x00\x00" + struct.pack(">III", offset, len(payload), len(payload))
with tempfile.TemporaryDirectory() as temp:
path = Path(temp) / "ftl.dat"
path.write_bytes(header + record + name + payload)
self.assertEqual(Pkg(path).read("data/test.txt"), payload)
def test_system_room_mapping_only_includes_installed_systems(self):
xml = b'<FTL><shipBlueprint name="PLAYER_SHIP_HARD" layout="kestral"><systemList><pilot room="0" start="true"/><drones room="2" start="false"/></systemList></shipBlueprint></FTL>'
name, systems = system_rooms(xml, "kestral")
self.assertEqual(name, "PLAYER_SHIP_HARD")
self.assertEqual(systems, {"0": "pilot"})
def test_ship_texture_matches_native_dimensions_and_crop_offsets(self):
def png(width, height):
return b"\x89PNG\r\n\x1a\n" + struct.pack(">I", 13) + b"IHDR" + struct.pack(">II", width, height)
class FakePkg:
entries = {
"data/test.txt": b"ROOM 0 0 0 2 2",
"data/test.xml": b'<ship><img x="-20" y="-30" w="100" h="120"/></ship>',
"data/blueprints.xml": b"<FTL/>",
"img/ships_noglow/test_base.png": png(60, 80),
"img/ships_glow/test_base.png": png(100, 120),
}
def read(self, name):
return self.entries[name]
with tempfile.TemporaryDirectory() as folder:
target = Path(folder)
result = build_ship(FakePkg(), "test", target)
self.assertEqual(result["image_path"], "img/ships_glow/test_base.png")
result = build_ship(FakePkg(), "test", target,
native_image_rect={"x": 5, "y": -7, "w": 60, "h": 80})
self.assertEqual(result["image_path"], "img/ships_noglow/test_base.png")
import json
saved = json.loads((target / "test.json").read_text(encoding="utf-8"))
self.assertEqual(saved["image_rect"], {"x": 5, "y": -7, "w": 60, "h": 80})
self.assertEqual(saved["texture_size"], [60, 80])
if __name__ == "__main__":
unittest.main()
+91
View File
@@ -0,0 +1,91 @@
extends SceneTree
var failures := 0
var transport_path := "res://local_game_data/transport-test/screen_frame.rgba"
func check(value: bool, message: String) -> void:
if not value:
failures += 1
push_error(message)
func _initialize() -> void:
call_deferred("run")
func write_frame(image: Image, sequence: int) -> void:
var header := PackedByteArray()
header.resize(36)
for i in range(4): header[i] = "FVR1".to_ascii_buffer()[i]
header.encode_u32(4, 1)
header.encode_u32(8, image.get_width())
header.encode_u32(12, image.get_height())
header.encode_u32(16, 4)
header.encode_u64(20, sequence)
header.encode_double(28, 123.5)
var file := FileAccess.open(transport_path, FileAccess.WRITE)
file.store_buffer(header)
file.store_buffer(image.get_data())
file.close()
func run() -> void:
var surface: Node3D = load("res://scripts/hud_surface.gd").new()
surface.frame_file = "screen_frame.png"
root.add_child(surface)
DirAccess.make_dir_recursive_absolute(ProjectSettings.globalize_path("res://local_game_data/transport-test"))
surface.canvas.base_path = ProjectSettings.globalize_path("res://local_game_data/transport-test")
surface.set_process(false)
var image := Image.create(960, 540, false, Image.FORMAT_RGBA8)
image.fill(Color(0.1, 0.8, 0.2, 1))
image.set_pixel(536, 281, Color(0.9, 0.1, 0.2, 0))
write_frame(image, 1)
surface.canvas.poll_original_frame()
check(surface.canvas.frame_image.get_size() == Vector2i(960, 540), "Live raw frames must retain their reduced transport dimensions")
check(surface.canvas.frame_image.get_pixel(0, 0).g > 0.7, "Raw frame orientation must be top-down RGBA")
var texture: Texture2D = surface.canvas.original_frame
var count: int = surface.canvas.received_frames
surface.canvas.poll_original_frame()
check(surface.canvas.received_frames == count, "An unchanged raw sequence must skip texture upload")
var broken := FileAccess.open(transport_path, FileAccess.WRITE)
broken.store_buffer("FVR1".to_ascii_buffer())
broken.close()
surface.canvas.poll_original_frame()
check(surface.canvas.received_frames == count and surface.canvas.original_frame == texture, "Truncated raw headers must retain the last complete frame")
write_frame(image, 1)
broken = FileAccess.open(transport_path, FileAccess.READ_WRITE)
broken.seek(8)
broken.store_32(5000)
broken.close()
surface.canvas.poll_original_frame()
check(surface.canvas.received_frames == count, "Malformed raw dimensions must never allocate or upload a texture")
check(surface.canvas.native_alpha_at(Vector2i(715, 375)) < 0.1, "Native alpha picking must sample reduced image coordinates")
image.set_pixel(536, 281, Color(0.9, 0.1, 0.2, 1))
write_frame(image, 2)
surface.canvas.poll_original_frame()
check(surface.canvas.original_frame == texture, "Reduced raw frames must reuse their GPU texture")
surface.set_crop(Rect2(335, 90, 760, 570), Vector2(1.02, 0.765))
var center: Variant = surface.ray_pixel(Vector3(0, 0, 1), Vector3.FORWARD)
check(center == Vector2i(715, 375), "Floating map centers must round to native pixels rather than truncate")
check(surface.canvas.native_used_rect() == Rect2i(0, 0, 1280, 720), "Panel bounds must be expressed in native pixels")
check(surface.viewport.size == Vector2i(960, 540), "Tactical viewport must match its reduced transport")
for i in range(4): await process_frame
if RenderingServer.get_current_rendering_method() != "gl_compatibility" and not DisplayServer.get_name() == "headless":
await RenderingServer.frame_post_draw
var rendered: Image = surface.viewport.get_texture().get_image()
check(rendered.get_pixel(0, 0).g > 0.7, "Reduced native texture crop must render through the logical canvas")
var full := Image.create(1280, 720, false, Image.FORMAT_RGBA8)
full.fill(Color(0.2, 0.4, 0.9, 1))
write_frame(full, 3)
surface.canvas.poll_original_frame()
check(surface.viewport.size == Vector2i(1280, 720), "Full-resolution jump-map frames must restore the native viewport resolution")
check(surface.viewport.size_2d_override == Vector2i(1280, 720) and surface.viewport.size_2d_override_stretch, "Resolution changes must preserve the logical native canvas")
check(surface.ray_pixel(Vector3(0, 0, 1), Vector3.FORWARD) == Vector2i(715, 375), "Native map picking must remain identical after resolution changes")
write_frame(image, 4)
surface.canvas.poll_original_frame()
check(surface.viewport.size == Vector2i(960, 540), "Returning to Tactical must reduce the viewport again")
DirAccess.remove_absolute(ProjectSettings.globalize_path(transport_path))
DirAccess.remove_absolute(ProjectSettings.globalize_path("res://local_game_data/transport-test"))
print("FRAME_TRANSPORT_TESTS ", "PASS" if failures == 0 else "FAIL %d" % failures)
quit(0 if failures == 0 else 1)
+390
View File
@@ -0,0 +1,390 @@
extends SceneTree
var failures := 0
func check(condition: bool, message: String) -> void:
if not condition:
failures += 1
push_error(message)
func _initialize() -> void:
call_deferred("run")
func run() -> void:
var scene := load("res://tools/input_harness.gd").new() as Node3D
root.add_child(scene)
await process_frame
scene.set_process(false)
var tracker := XRControllerTracker.new()
tracker.type = XRServer.TRACKER_CONTROLLER
tracker.name = "right_hand"
tracker.set_pose("aim", Transform3D(Basis.IDENTITY, Vector3(0.1,1.2,1)), Vector3.ZERO, Vector3.ZERO, XRPose.XR_TRACKING_CONFIDENCE_HIGH)
XRServer.add_tracker(tracker)
scene._resolve_controller_pose(scene.right_hand)
check(scene.right_hand.pose == "aim" and scene.right_hand.get_is_active(), "Right hand must use the active aim tracker pose")
scene._update_pointer()
check(scene.right_laser.visible and scene.right_laser.mesh is CylinderMesh, "Tracked hand must draw a stereo-visible ray")
check(scene.right_laser.material_override.no_depth_test, "Ray must remain visible over the native menu")
check(scene.right_laser.material_override.transparency == BaseMaterial3D.TRANSPARENCY_ALPHA and scene.right_laser.material_override.render_priority == 127,"Pointer must render after transparent HUD surfaces")
check(is_equal_approx(scene.right_laser.mesh.height,1.0),"Pointer length must use node scaling, without regenerating its mesh every frame")
tracker.set_input("trigger",0.8)
check(scene._pressed(scene.right_hand,"trigger"), "Analog trigger must work without trigger_click")
tracker.invalidate_pose("aim")
tracker.set_pose("default",Transform3D.IDENTITY,Vector3.ZERO,Vector3.ZERO,XRPose.XR_TRACKING_CONFIDENCE_HIGH)
scene._resolve_controller_pose(scene.right_hand)
check(scene.right_hand.pose == "default", "Missing aim tracking must fall back to default")
scene._update_input_diagnostics(1.1)
scene.hud_state = {"ready":true,"ui_mode":"game","navigation":{"jump":true,"ship":true,"store":false}}
scene.xr_active = true
scene._refresh_navigation()
var names := []
for button in scene.nav_buttons:
if button.visible: names.append(button.get_meta("action"))
else: check(button.collision_layer == 0,"Hidden navigation buttons must not intercept rays")
check("jump" in names and "store" not in names,"Navigation must reflect live availability")
scene.hud_state.map_open = true
scene._refresh_navigation()
check(scene.map_surface.visible and scene.map_surface.get_parent() == scene,"Actual jump map must float in the world above the ship")
check(scene.map_surface.global_position.y >= scene.player_ship.global_position.y + 0.65,"Jump map must have comfortable clearance above the ship")
var pilot_id := -1
for id in scene.player_ship.room_nodes:
if scene.player_ship.room_nodes[id].get_meta("system_role", "") == "pilot": pilot_id = int(id)
check(pilot_id >= 0,"Owned Kestrel layout must provide its actual piloting room")
if pilot_id >= 0:
var pilot_point: Vector3 = scene.player_ship.to_global(scene.player_ship.room_points[pilot_id])
var bow: Vector3 = scene.player_ship.global_basis.x.normalized()
var projected: Vector3 = scene.map_surface.global_position - pilot_point
check(projected.dot(bow) > 0.20 and projected.y > 0.60,"Jump map must project above and ahead of the pilot instead of the ship center")
var original_table: Transform3D = scene.tabletop_root.transform
scene.tabletop_root.rotation.y = PI * 0.5
scene.tabletop_root.scale *= 1.8
scene._position_world_panels()
pilot_point = scene.player_ship.to_global(scene.player_ship.room_points[pilot_id])
bow = scene.player_ship.global_basis.x.normalized()
projected = scene.map_surface.global_position - pilot_point
check(projected.dot(bow) > 0.20 and projected.y > 0.60 and projected.length() < 0.70,"Pilot projection must follow ship rotation while keeping world-metre clearance at different table scales")
var saved_vertical: int = int(scene.player_ship.layout_data.get("vertical",0))
scene.player_ship.layout_data.vertical = 1
scene._update_ship_positions()
scene._position_world_panels()
pilot_point = scene.player_ship.to_global(scene.player_ship.room_points[pilot_id])
projected = scene.map_surface.global_position - pilot_point
check(projected.dot(-scene.player_ship.global_basis.z.normalized()) > 0.20,"Vertical ship layouts must project ahead of their actual bow")
scene.player_ship.layout_data.vertical = saved_vertical
scene._update_ship_positions()
scene.tabletop_root.transform = original_table
# Read the actual room roles, not the extracted blueprint's first pilot.
var alternate: int = int(scene.player_ship.room_nodes.keys().back())
if alternate != pilot_id:
scene.player_ship.room_nodes[pilot_id].set_meta("system_role", "")
var saved_role: String = scene.player_ship.room_nodes[alternate].get_meta("system_role", "")
scene.player_ship.room_nodes[alternate].set_meta("system_role", "pilot")
scene._position_world_panels()
pilot_point = scene.player_ship.to_global(scene.player_ship.room_points[alternate])
check(Vector2(scene.map_surface.global_position.x-pilot_point.x,scene.map_surface.global_position.z-pilot_point.z).length() < 0.25,"A different piloting room must move the projected map anchor with it")
scene.player_ship.room_nodes[alternate].set_meta("system_role", saved_role)
scene.player_ship.room_nodes[pilot_id].set_meta("system_role", "pilot")
scene._position_world_panels()
check(is_equal_approx(scene.enemy_hull_bar.global_position.y-scene.enemy_ship.global_position.y,0.52),"Enemy hull bar must sit slightly lower while retaining clearance over the ship")
scene.hud_state.map_open = false
scene.hud_state.tactical = true
scene._refresh_navigation()
check(scene.nav_buttons[0].get_meta("action") == "tactical","Tactical view must close with its own toggle, not Escape")
scene.hud_state.map_open = true
scene.hud_state.tactical = false
scene._refresh_navigation()
var image := Image.create(1280,720,false,Image.FORMAT_RGBA8)
image.fill(Color.WHITE)
scene.map_surface.canvas.frame_image = image
scene.nav_panel.visible = true
var pixel: Variant = scene.map_surface.ray_pixel(scene.map_surface.to_global(Vector3(0,0,1)), -scene.map_surface.global_basis.z)
check(pixel == Vector2i(715,375),"Cropped map pointer must map to the native game's original coordinates")
scene._select_from_ray(scene.map_surface.to_global(Vector3(0,0,1)), -scene.map_surface.global_basis.z)
scene._release_pointer(scene.map_surface.to_global(Vector3(0,0,1)), -scene.map_surface.global_basis.z)
check(scene.commands.size() == 2 and scene.commands[0].data.phase == "down" and scene.commands[1].data.phase == "up","Map selection must dispatch a full native mouse click")
scene.nav_panel.visible = false
check(scene.map_surface.ray_pixel(scene.map_surface.to_global(Vector3(0,0,1)), -scene.map_surface.global_basis.z) != null,"World jump map must stay usable when hand tracking is hidden")
var map: OpenXRActionMap = load("res://openxr_action_map.tres")
check(map.find_interaction_profile("/interaction_profiles/valve/frame_controller_valve") != null,"Explicit Frame profile must ship with the client")
check(is_equal_approx(scene.nav_panel.scale.x, 0.6) and is_equal_approx(scene.hud_surface.scale.x, 0.64) and scene.hud_surface.position.y < -0.1,"HUD must be lower and narrower while retaining the hand panel size")
check(scene.hud_surface.render_order == 100 and scene.panel_theme.get_child(1).material_override.render_priority > scene.pause_label.render_priority and scene.tracking_label.render_priority < 120,"Hand screen must render over all HUD layers, including pause and modifier labels")
check(scene.help_label.get_script().resource_path.ends_with("controller_help.gd"),"Help must use a controller infographic instead of plain text")
check(scene.hover_marker.mesh.radius <= 0.003 and scene.right_laser.mesh.top_radius <= 0.001,"Pointer endpoint must support precise native UI selection")
scene.wheel.global_position = scene.camera.get_camera_transform().origin + Vector3(0.3,-0.5,-0.4)
scene._face_front(scene.wheel)
var to_head: Vector3 = (scene.camera.get_camera_transform().origin-scene.wheel.global_position).normalized()
check(scene.wheel.global_basis.z.dot(to_head) > 0.999 and scene.wheel.global_basis.z.y > 0,"Wheel face must tilt up toward the real viewer rather than the ground")
scene.hud_state = {"ready":true,"ui_mode":"game","navigation":{"jump":true},"player":{"weapons":[{}],"systems":{"shields":true}},"drone_slots":1}
scene.nav_page = "navigation"
scene._cycle_hand_page(1)
check(scene.nav_page == "tactical" and scene.commands.back().data.screen == "tactical","Dpad right must cycle into tactical")
scene.hud_state.tactical = true
scene._cycle_hand_page(1)
check(scene.pending_page == "shortcuts" and scene.commands.back().data.screen == "tactical","Changing screen must first close the native tactical view")
scene.hud_state.tactical = false
scene._refresh_wheel()
check(scene.wheel.entries[0].enabled and not scene.wheel.entries[1].enabled and scene.wheel.entries[4].enabled,"Wheel must show only equipped weapon/drone slots as available")
scene.wheel.choose(Vector2(0,1))
check(scene.wheel.selected == 0,"Joystick up must select slot 1")
scene.wheel.choose(Vector2.ZERO)
check(scene.wheel.selected == -1,"Wheel center must cancel")
scene.hud_state.event_open = true
scene.hud_state.dialog = {"id":"test","choices":[{"index":0,"enabled":true}]}
scene.wheel_dialog_id = "test"
scene._refresh_wheel()
scene.wheel.choose(Vector2(0,1))
scene._commit_wheel()
var command_count: int = scene.commands.size()
scene._commit_wheel()
check(scene.commands.back().action == "event_choice" and scene.commands.size() == command_count,"Event wheel must commit once")
scene.event_surface.visible = true
scene.event_surface.canvas.frame_image = image
scene.event_surface.source_rect = Rect2(313,138,650,390)
var event_pixel: Variant = scene.event_surface.ray_pixel(scene.event_surface.to_global(Vector3(0,0,1)), -scene.event_surface.global_basis.z)
check(event_pixel == Vector2i(638,333),"Cropped event ray must retain the original native coordinates")
# A visible HUD pixel takes precedence over a physically nearer world panel.
scene.hud_surface.canvas.frame_image = image
scene.hud_surface.global_transform = Transform3D.IDENTITY
scene.world_surface.global_transform = Transform3D(Basis.IDENTITY,Vector3(0,0,0.4))
scene.world_surface.visible = true
scene.world_surface.canvas.frame_image = image
var top_hit: Dictionary = scene._surface_hit(Vector3(0,0,1),Vector3.FORWARD)
check(top_hit.surface == scene.hud_surface,"Rendered HUD priority must match click priority")
# A hand map wins against an overlapping opaque HUD pixel, regardless of distance.
scene.nav_panel.global_transform = Transform3D(Basis.IDENTITY,Vector3(0,0,0.4))
scene.nav_panel.visible = true
scene.hud_state.map_open = false
scene.hud_state.tactical = true
scene._refresh_navigation()
scene.map_surface.visible = true
scene.map_surface.canvas.frame_image = image
var hand_top: Dictionary = scene._surface_hit(Vector3(0,0,1),Vector3.FORWARD)
check(hand_top.get("surface") == scene.map_surface,"Controller screen click priority must match its foreground draw priority")
scene.map_surface.visible = false
scene._select_from_ray(Vector3(0,0,1),Vector3.FORWARD)
check(scene.held_surface == null,"Blank hand panel pixels must block clicks into the HUD behind them")
scene.nav_panel.visible = false
var window_image := Image.create(1280,720,false,Image.FORMAT_RGBA8)
var body_image := Image.create(650,520,false,Image.FORMAT_RGBA8)
body_image.fill(Color.WHITE)
window_image.blit_rect(body_image,Rect2i(0,0,650,520),Vector2i(300,90))
scene.world_surface.canvas.frame_image = window_image
scene.world_panel_crop_pending = true
scene.world_panel_open_age = 0.3
scene.world_panel_opened_at = 100.0
scene.world_surface.canvas.capture_time_unix = 99.0
var old_crop: Rect2 = scene.world_surface.source_rect
scene._position_world_panels()
check(scene.world_panel_crop_pending and scene.world_surface.source_rect == old_crop,"A previous panel's cached frame must not lock a new window crop")
scene.world_surface.canvas.capture_time_unix = 100.1
scene._position_world_panels()
var fixed_crop: Rect2 = scene.world_surface.source_rect
check(fixed_crop == Rect2(300,90,650,520),"Native world window crop must preserve its original pixel coordinates")
check(scene.world_surface.global_position.y > scene.player_ship.global_position.y,"Ship and store windows must hang above the VR player ship")
scene.world_surface.canvas.frame_image = image
scene._position_world_panels()
check(scene.world_surface.source_rect == fixed_crop,"Hover image changes must not resize a floating window or change pointer mapping")
scene.world_surface.canvas.capture_time_unix = 0.0
scene.world_panel_open_frame_count = scene.world_surface.canvas.received_frames
check(not scene._world_panel_has_fresh_frame(),"Legacy PNG transport must wait for a new image on panel opening")
scene.world_surface.canvas.received_frames += 1
check(scene._world_panel_has_fresh_frame(),"A newly decoded legacy PNG must remain usable without a raw timestamp")
scene.world_panel_kind = "sell"
scene.world_panel_crop_pending = true
scene._position_world_panels()
check(scene.world_surface.source_rect == Rect2(0,0,1280,720),"Shop crop must retain detached sell drop boxes and future hover panels")
check(scene.world_surface.surface_size.x <= 1.03 and scene.event_surface.surface_size.x < 1.0,"Floating ship/store and event panels must be smaller")
# The authoritative pause cue does not inherit a cached native image or forced menu freeze.
scene.hud_state = {"ready":true,"paused":true}
scene.paused = true
scene._update_hud()
scene._refresh_navigation()
check(scene.pause_label.visible and "PAUSED" in scene.nav_title.text,"Paused status must be present on HUD and hand screen from the same state")
scene.hud_state = {"ready":true,"paused":false,"frozen":true}
scene._update_hud()
scene._refresh_navigation()
check(not scene.pause_label.visible and "PAUSED" not in scene.nav_title.text,"A menu freeze must not leave a stale player-pause cue")
# Text buttons send native text operations, with no leaked UI mouse-up/hotkeys.
scene.wheel.visible = true
scene.wheel_committed = false
scene._apply_text_entry({"active":true,"id":"rename-1","kind":"crew","text":"Test","cursor":4})
check(not scene.wheel.visible and scene.wheel_committed,"Native renaming must cancel an already-open action wheel")
var count_before_modal_wheel: int = scene.commands.size()
scene.wheel_committed = false
scene._commit_wheel()
check(scene.commands.size() == count_before_modal_wheel,"Wheel release must not send a gameplay key into native text entry")
scene.rename_keyboard.global_transform = Transform3D.IDENTITY
var key: Dictionary = scene.rename_keyboard.keys[10]
var key_pixel: Vector2 = key.rect.get_center()
var key_world: Vector3 = scene.rename_keyboard.to_global(Vector3((key_pixel.x/1000.0-0.5)*0.86,(0.5-key_pixel.y/454.0)*0.39,0))
scene._select_from_ray(key_world+Vector3(0,0,1),Vector3.FORWARD)
check(scene.commands.back().action == "text_input" and scene.commands.back().data.op == "insert" and scene.commands.back().data.text == "Q" and scene.commands.back().data.entry_id == "rename-1","VR keyboard must insert into the exact active native name field")
var count_before_release: int = scene.commands.size()
scene._release_pointer(key_world+Vector3(0,0,1),Vector3.FORWARD)
check(scene.commands.size() == count_before_release,"Keyboard release must not leak a native UI click")
scene._select_from_ray(Vector3(0,20,0),Vector3.DOWN)
check(scene.commands.size() == count_before_release,"Active renaming must block background HUD, menu and scene clicks")
scene._secondary_click(Vector3.ZERO,Vector3.DOWN)
check(scene.commands.back().action == "text_input" and scene.commands.back().data.op == "cancel","B must cancel the native rename field")
scene.rename_keyboard.set_entry({"active":false})
for surface in [scene.hud_surface,scene.world_surface,scene.event_surface,scene.map_surface]: surface.visible = false
scene.hud_state = {"ready":true,"ui_mode":"screen","tactical":true}
scene.demo_mode = false
scene.bridge_connected = true
# Weapon targets and beam endpoints ignore miniatures and door colliders.
scene.weapon_selected = true
scene.battle = true
var enemy_room: int = scene.enemy_ship.room_points.keys()[0]
var enemy_point: Vector3 = scene.enemy_ship.room_target(enemy_room)
var obstruction := Area3D.new()
obstruction.set_meta("kind","door")
obstruction.set_meta("ship","enemy")
var obstruction_shape := CollisionShape3D.new()
var obstruction_box := BoxShape3D.new()
obstruction_box.size = Vector3(0.1,0.1,0.1)
obstruction_shape.shape = obstruction_box
obstruction.add_child(obstruction_shape)
scene.add_child(obstruction)
obstruction.global_position = enemy_point + Vector3(0,0.2,0)
await physics_frame
scene._select_from_ray(enemy_point+Vector3(0,1,0),Vector3.DOWN)
check(scene.commands.back().action == "target_room" and scene.commands.back().data.room_id == enemy_room,"Weapon room selection must bypass enemy crew and door colliders")
scene.beam_drag = true
scene._release_pointer(enemy_point+Vector3(0,1,0),Vector3.DOWN)
check(scene.commands.back().action == "target_room" and scene.commands.back().data.phase == "up","Beam release must use the same room-floor picking")
for kind in ["mind", "hacking", "teleporter"]:
scene.hud_state.targeting = {"active":true,"kind":kind,"ships":["enemy"]}
scene._select_from_ray(enemy_point+Vector3(0,1,0),Vector3.DOWN)
check(scene.commands.back().action == "target_room" and scene.commands.back().data.ship == "enemy" and not scene.beam_drag,"Native %s targeting must work on 3D enemy room floors" % kind)
scene.hud_state.targeting = {"active":false}
obstruction.queue_free()
var room_id: int = scene.player_ship.room_points.keys()[0]
var room_point: Vector3 = scene.player_ship.room_target(room_id)
var crew := Area3D.new()
crew.set_meta("kind","crew")
crew.set_meta("crew_id","grab-test")
crew.set_meta("ship","player")
var shape := CollisionShape3D.new()
var sphere := SphereShape3D.new()
sphere.radius = 0.04
shape.shape = sphere
crew.add_child(shape)
scene.add_child(crew)
crew.global_position = room_point + Vector3(0,0.2,0)
await physics_frame
scene.hud_state.blocking_ui = true
var blocked_count: int = scene.commands.size()
scene._select_from_ray(room_point+Vector3(0,1,0),Vector3.DOWN)
check(scene.commands.size() == blocked_count,"Real native dialogs must block background crew commands even in Tactical")
scene.hud_state.blocking_ui = false
scene.hud_state.targeting = {"active":true,"kind":"mind","ships":["player","enemy"]}
scene._select_from_ray(room_point+Vector3(0,1,0),Vector3.DOWN)
check(scene.commands.back().action == "target_room" and scene.commands.back().data.ship == "player" and scene.grabbed_crew == null,"Friendly mind control must target the room instead of grabbing its crew")
scene.hud_state.targeting = {"active":false}
scene.shift_down = true
scene._select_from_ray(room_point+Vector3(0,1,0),Vector3.DOWN)
check(scene.grabbed_crew == crew and scene.commands.back().action == "select_crew","Holding trigger on crew must begin a native selection and visual grab")
scene.crew_id_selected = "older-native-selected-crew"
scene.shift_down = false
scene._release_pointer(room_point+Vector3(0,1,0),Vector3.DOWN)
check(scene.grabbed_crew == null and scene.commands.back().action == "move_crew" and scene.commands.back().data.room_id == room_id and scene.commands.back().data.crew_id == "grab-test","Dropping crew must retain the grabbed id despite asynchronous native selection updates")
check(scene.commands.back().data.modifiers.shift,"Quick Shift-grabs must retain group selection even when grip is released before native selection acknowledgement")
scene._begin_crew_grab(crew,room_point+Vector3(0,1,0))
scene._secondary_click(Vector3(0,20,0),Vector3.UP)
check(scene.grabbed_crew == null and scene.commands.back().action == "cancel","B must cancel the grab without issuing a move")
crew.queue_free()
scene.nav_page = "power"
scene.hud_state.tactical = false
scene.hud_state.ui_mode = "game"
scene.hud_state.player = {"system_status":[{"id":0,"key":"shields","powerable":true,"allocated":2,"max_power":4},{"id":1,"key":"engines","powerable":true,"allocated":1,"max_power":3}]}
scene._refresh_navigation()
scene.nav_panel.visible = true
scene.nav_panel.global_transform = Transform3D.IDENTITY
var card: Rect2 = scene.panel_theme.power_card_rect(0)
var card_pixel: Vector2 = card.position + Vector2(3,card.size.y*0.5)
var card_point := Vector3((card_pixel.x/1150.0-0.5)*0.92,(0.5-card_pixel.y/900.0)*0.72,0.012)
var oblique_from := card_point + Vector3(-0.65,0,0.3)
var oblique_hit: Dictionary = scene._hand_panel_hit(oblique_from,(card_point-oblique_from).normalized())
check(oblique_hit.get("system_key", "") == "shields","Oblique power-page pointing must hit the visible card edge on the actual display plane")
card = scene.panel_theme.power_card_rect(1)
card_pixel = card.get_center()
card_point = Vector3((card_pixel.x/1150.0-0.5)*0.92,(0.5-card_pixel.y/900.0)*0.72,0.012)
scene.pointer_transform = Transform3D(Basis.IDENTITY,card_point+Vector3(0,0,1))
scene.ray_filter.initialized = true
scene._update_pointer()
check(scene.panel_theme.selected_system().key == "engines","Power pointing must select the current card before a same-frame left trigger press")
scene.panel_theme.step_selection(-1)
scene._update_pointer()
scene._hover_ui(scene.pointer_transform.origin,Vector3.FORWARD)
check(scene.panel_theme.selected_system().key == "shields","A stationary power-page ray must not override X/Y system selection")
scene.ray_filter.reset()
scene.panel_theme.select_system("shields")
scene.nav_panel.visible = false
scene._change_system_power(1)
check(scene.commands.back().action == "system_power" and scene.commands.back().data.system_id == 0 and scene.commands.back().data.direction == 1 and not scene.commands.back().data.modifiers.shift,"Power trigger must use installed system identity and neutral modifiers")
scene._update_power_controls(true,false,true)
check(scene.commands.back().data.system_id == 1 and scene.commands.back().data.direction == -1,"X/Y selection and bumper removal must act on the newly selected system")
var held_count: int = scene.commands.size()
scene._update_power_controls(true,false,true)
check(scene.commands.size() == held_count,"Held bumper and X/Y must dispatch once per press")
scene._update_power_controls(false,false,false)
scene._update_power_controls(false,true,false)
check(scene.panel_theme.selected_system().key == "shields","Previous-system button must wrap back through installed systems")
scene.nav_page = "tactical"
scene.pending_page = ""
scene.hud_state.tactical = true
scene.hud_state.ui_mode = "screen"
scene._toggle_tactical()
check(scene.commands.back().action == "navigate" and scene.commands.back().data.screen == "tactical","Dpad Down must close Tactical without pausing")
scene.hud_state.tactical = false
scene.hud_state.ui_mode = "game"
scene.pending_page = ""
scene._toggle_tactical()
check(scene.commands.back().data.screen == "tactical","Dpad Down must open Tactical from gameplay")
check(scene.pause_label.position.y > 0.0,"Authoritative pause cue must remain above the HUD center, clear of Autofire")
var filter := preload("res://scripts/pose_filter.gd").new()
var steady := Transform3D(Basis.IDENTITY,Vector3(0.2,1.2,-0.3))
filter.update(steady,1.0/90.0)
for i in range(90):
var jitter := steady
jitter.origin.x += 0.001 if i%2==0 else -0.001
jitter.basis = Basis(Vector3.UP,deg_to_rad(0.1 if i%2==0 else -0.1))
filter.update(jitter,1.0/90.0)
check(filter.value.is_equal_approx(steady),"Submillimetre hand/ray jitter must not move a stationary panel")
var moved := steady
moved.origin.x += 0.12
for i in range(9): filter.update(moved,1.0/90.0)
check(filter.value.origin.distance_to(moved.origin)<0.012,"Deliberate hand movement must follow within a tenth of a second")
filter.reset()
check(filter.update(moved,1.0/90.0).is_equal_approx(moved),"Reacquired tracking must snap rather than retain the previous pose")
# Recenter is explicit; combat arrival must preserve the player's ship anchor.
scene.xr_active = true
scene._recenter()
var head_point: Vector3 = scene.camera.get_camera_transform().origin
var offset: Vector3 = scene.tabletop_root.global_position - head_point
var forward: Vector3 = -scene.camera.get_camera_transform().basis.z
forward.y = 0
check(Vector2(offset.x,offset.z).length() > 0.8 and offset.dot(forward.normalized()) > 0.8 and is_equal_approx(offset.y,-0.95),"Recenter must place the table in front of the headset at its established height")
scene.battle = false
scene._update_ship_positions()
var player_position: Vector3 = scene.player_ship.global_position
scene.battle = true
scene._update_ship_positions()
check(scene.player_ship.global_position.is_equal_approx(player_position),"Another ship arriving must not shift the player's placed table")
var head_tracker := XRPositionalTracker.new()
head_tracker.name = "head"
head_tracker.type = XRServer.TRACKER_HEAD
head_tracker.set_pose("default",Transform3D(Basis.IDENTITY,Vector3(1.4,1.65,-0.9)),Vector3.ZERO,Vector3.ZERO,XRPose.XR_TRACKING_CONFIDENCE_HIGH)
XRServer.add_tracker(head_tracker)
scene.initial_table_placed = false
scene._place_initial_table()
check(scene.initial_table_placed,"First valid headset pose must place the tabletop exactly once")
var initial_position: Vector3 = scene.tabletop_root.global_position
head_tracker.set_pose("default",Transform3D(Basis.IDENTITY,Vector3(2.4,1.65,-0.9)),Vector3.ZERO,Vector3.ZERO,XRPose.XR_TRACKING_CONFIDENCE_HIGH)
scene._place_initial_table()
check(scene.tabletop_root.global_position.is_equal_approx(initial_position),"Walking after initial placement must not drag the ship with the headset")
head_tracker.set_pose("default",Transform3D.IDENTITY,Vector3.ZERO,Vector3.ZERO,XRPose.XR_TRACKING_CONFIDENCE_HIGH)
scene._recenter()
check(scene.player_ship.global_position.y < scene.camera.get_camera_transform().origin.y - 0.3,"Head-relative XR spaces with zero head height must still place the ship below the headset")
XRServer.remove_tracker(head_tracker)
XRServer.remove_tracker(tracker)
scene.queue_free()
await process_frame
print("INPUT_TESTS ", "PASS" if failures == 0 else "FAIL %d" % failures)
quit(0 if failures == 0 else 1)
+1
View File
@@ -0,0 +1 @@
uid://sr4hnfgun5is
+288
View File
@@ -0,0 +1,288 @@
extends SceneTree
const ShipModel = preload("res://scripts/ship_model.gd")
const CrewModel = preload("res://scripts/voxel_crew.gd")
const WeaponModel = preload("res://scripts/voxel_weapon.gd")
const DroneModel = preload("res://scripts/voxel_drone.gd")
const UiAssets = preload("res://scripts/ftl_ui_assets.gd")
const HullBar = preload("res://scripts/enemy_hull_bar.gd")
var failures := 0
func _initialize() -> void:
call_deferred("_run")
func _check(ok: bool, message: String) -> void:
if not ok:
failures += 1
push_error(message)
func _signature(node: Node) -> String:
var result := ""
if node is MeshInstance3D:
var arrays: Array = node.mesh.surface_get_arrays(0)
result += str(arrays[Mesh.ARRAY_VERTEX])
for child in node.get_children():
result += _signature(child)
return result
func _run() -> void:
var signatures: Dictionary = {}
for race in CrewModel.SPECIES:
var model := CrewModel.new()
root.add_child(model)
model.build(race)
var signature := _signature(model)
_check(not signatures.has(signature), "%s must have its own geometry, not a recolor" % race)
signatures[signature] = true
var poses: Dictionary = {}
for state in ["work", "repair", "run", "fight"]:
model.set_state({"working": state == "work", "repairing": state == "repair", "running": state == "run", "fighting": state == "fight"})
model.animation_time = 0.31
model.animate(0.11)
var pose := str(model.arms[0].rotation) + str(model.arms[1].rotation) + str(model.torso.position) + str(model.torso.rotation)
_check(model.animation_state == state, "%s must select native %s animation" % [race, state])
_check(not poses.has(pose), "%s %s must have a distinct animated pose" % [race, state])
poses[pose] = true
_check(model.tool.visible == (state == "repair"), "Repair tool must only appear during repairs")
model.queue_free()
var drone_signatures: Dictionary = {}
for family in ["combat", "beam", "defense", "hacking", "shield", "boarding", "anti_drone", "repair", "battle", "ion_boarder"]:
var drone := DroneModel.new()
root.add_child(drone)
var exterior: bool = family not in ["repair", "battle", "ion_boarder"]
var native := {"kind": family, "name": family.to_upper() + "_1", "species": family, "is_space": exterior, "powered": true, "deployed": true}
drone.build(native)
_check(drone.family == family, "Native %s drone identifiers must choose the matching model" % family)
var signature := _signature(drone)
_check(not drone_signatures.has(signature), "%s drones must have unique three-dimensional geometry rather than a recolor" % family)
drone_signatures[signature] = true
var duplicate := DroneModel.new()
root.add_child(duplicate)
duplicate.build(native)
_check(duplicate.body.mesh == drone.body.mesh and duplicate.body.material_override == drone.body.material_override, "Matching drones must reuse immutable body mesh and material resources")
_check(drone.body.mesh.get_aabb().size.y > 0.025, "Drone geometry must have visible depth")
drone.set_live({"deployed": true, "powered": false})
_check(not drone.emitter.visible and not drone.thruster.visible, "Unpowered drones must not display active exhaust or emitter glow")
drone.set_live({"deployed": true, "powered": true, "firing": true, "fighting": true, "repairing": not exterior})
_check(drone.emitter.material_override.emission_energy_multiplier == 1.5, "Only native firing or fighting must brighten the emitter")
if not exterior:
drone.animate(0.2)
_check(not is_zero_approx(drone.tool.rotation.x), "Native interior drone activity must animate its tool")
drone.set_live({"deployed": true, "dead": true})
_check(not drone.visible, "Dead native drones must disappear")
duplicate.queue_free()
drone.queue_free()
_check(DroneModel.model_family({"is_space": false, "species": "battle", "name": "Robot de combat"}) == "battle", "Interior drone family must use native species even with a localized crew name")
_check(DroneModel.model_family({"is_space": false, "species": "repair", "name": "Board Master"}) == "repair", "A renamed interior drone must not override its authoritative native species")
var mk_one := DroneModel.new()
var mk_two := DroneModel.new()
root.add_child(mk_one)
root.add_child(mk_two)
mk_one.build({"name": "COMBAT_1", "deployed": true})
mk_two.build({"name": "COMBAT_2", "deployed": true})
_check(_signature(mk_one) != _signature(mk_two), "Native combat Mk II must have a visibly distinct double-barrel assembly")
mk_one.queue_free()
mk_two.queue_free()
var weapon_signatures: Dictionary = {}
for kind in ["laser", "ion", "beam", "missile", "bomb", "flak"]:
var weapon := WeaponModel.new()
root.add_child(weapon)
weapon.build({"kind": kind, "powered": true, "charge": 2, "cooldown": 2})
var signature := _signature(weapon)
_check(not weapon_signatures.has(signature), "%s must have a distinctive weapon model" % kind)
weapon_signatures[signature] = true
_check(is_equal_approx(weapon.charge_fill.scale.x, 0.11), "Native full charge must fill the visible top-mounted charging strip")
weapon.set_live({"powered": true, "charge": 3, "cooldown": 12})
_check(is_equal_approx(weapon.charge, 0.25) and is_equal_approx(weapon.charge_fill.scale.x, 0.0275), "Native elapsed cooldown must drive quarter charge without invented progression")
var mesh_id := weapon.charge_fill.mesh.get_instance_id()
weapon.animate(0.5)
_check(is_equal_approx(weapon.charge, 0.25) and weapon.charge_fill.mesh.get_instance_id() == mesh_id, "Weapon animation must preserve native charge and reuse its indicator mesh")
weapon.set_live({"powered": false, "charge_fraction": 0.8})
_check(not weapon.emitter.visible and not weapon.charge_fill.visible, "Unpowered weapons must not display a charging or ready glow")
weapon.set_live({"powered": true, "firing": true})
weapon.animate(0.01)
_check(weapon.body.position.x < 0, "%s native firing must trigger recoil" % kind)
weapon.queue_free()
var charge_weapon := WeaponModel.new()
root.add_child(charge_weapon)
charge_weapon.build({"kind": "laser", "name": "CHARGE_LASER_2", "powered": true, "charge_fraction": 0.4, "charge_max": 4, "charge_level": 2})
_check(charge_weapon.charge_pips.size() == 4 and charge_weapon.charge_level == 2, "AE charge weapons must show actual stored shot count separately from cooldown")
_check(charge_weapon.charge_pips[0].material_override.albedo_color == Color("92e87d") and charge_weapon.charge_pips[2].material_override.albedo_color == Color("2a4245"), "Only natively stored charge pips may light up")
charge_weapon.queue_free()
var hull_bar := HullBar.new()
root.add_child(hull_bar)
hull_bar.build()
hull_bar.set_state({"hull": 7, "hull_max": 20})
_check(hull_bar.visible and hull_bar.hull == 7 and hull_bar.hull_max == 20, "Enemy life bar must display the actual native hull values")
hull_bar.position = Vector3(1, 0.4, -1)
var viewer := Vector3(0, 1.6, 0)
hull_bar.face_viewer(viewer)
_check(hull_bar.global_basis.z.dot((viewer - hull_bar.global_position).normalized()) > 0.99, "Enemy hull plate must face the viewer rather than the floor")
hull_bar.set_state({"hull": 0, "hull_max": 20, "destroyed": true})
_check(not hull_bar.visible, "Destroyed or absent ships must not retain a phantom enemy life bar")
hull_bar.queue_free()
# A cropped fallback texture must extrude through the same native rectangle
# as its visible deck, including a partially filled final sampling cell.
var mapped_hull := ShipModel.new()
root.add_child(mapped_hull)
var image := Image.create(10, 7, false, Image.FORMAT_RGBA8)
image.fill(Color.WHITE)
mapped_hull._make_hull_depth(image, {"x": -3, "y": 9, "w": 30, "h": 14}, Vector2.ZERO)
var mapped_multi: MultiMesh = mapped_hull.get_node("ExtrudedHull").multimesh
_check(mapped_multi.instance_count == 2, "Partial silhouette cells must retain their native extent")
# The headless dummy renderer does not retain MultiMesh transforms. Check
# their measured corner geometry, which the model keeps for shield fitting.
var first_center := (mapped_hull.shield_fit_points[0] + mapped_hull.shield_fit_points[7]) * 0.5
var first_width: float = mapped_hull.shield_fit_points[7].x - mapped_hull.shield_fit_points[0].x
var last_width: float = mapped_hull.shield_fit_points[15].x - mapped_hull.shield_fit_points[8].x
_check(is_equal_approx(first_center.x, 7.5 / 35.0 * ShipModel.TILE) and is_equal_approx(first_center.z, 16.0 / 35.0 * ShipModel.TILE), "Voxel centers must use the deck's native texture scale and offset")
_check(is_equal_approx(first_width, 21.0 / 35.0 * ShipModel.TILE) and is_equal_approx(last_width, 9.0 / 35.0 * ShipModel.TILE), "Hull voxel widths must follow the original image mapping without extending the last cell")
mapped_hull.queue_free()
var ship := ShipModel.new()
root.add_child(ship)
ship.build("kestral", false)
_check(ship.room_nodes[0].get_meta("system_role", "") == "pilot", "Initial blueprint roles must use canonical integer room IDs from JSON")
_check(ship.hull_voxel_count > 500, "Owned ship art must produce a detailed solid 3D silhouette")
_check(ship.get_node("ExtrudedHull").multimesh.instance_count == ship.hull_voxel_count, "Hull armor must use one batched MultiMesh")
# Recorded from the current owned native Kestrel GetBaseEllipse; verify the
# presentation shell also contains the newly added depth, not only its deck.
ship._apply_shield_shape({"center": {"x": 232, "y": 175}, "a": 350, "b": 220})
for point in ship.shield_fit_points:
_check(((point - ship.shield_center) / ship.shield_radii).length_squared() <= 1.001, "The fitted shield must contain hull and room-wall corners, including the keel")
_check(ship.shield_fit_padding <= 1.12 and ship.shield_radii.y < 1.0, "A fitted shield must cover the ship with limited footprint padding and compact height")
var room_id: int = ship.room_nodes.keys()[0]
var room_data := {"id": room_id, "oxygen": 100, "visible": true}
var crew := {"id": "model-test", "species": "engi", "name": "Test", "room": room_id, "x": 510, "y": 90, "controllable": true, "working": true, "owner": 0}
var door := {"id": 10000, "x": 400, "y": 100, "vertical": true, "open": false, "controllable": true}
var drone := {"id": "space-test", "name": "DEFENSE_1", "kind": "defense", "is_space": true, "deployed": true, "powered": true, "x": 350, "y": 200, "angle": 3.0}
ship.apply_live({"rooms": [room_data], "room_systems": {str(room_id): "shields"}, "crew": [crew], "doors": [door], "drones": [drone]})
var room: Area3D = ship.room_nodes[room_id]
_check(room.get_node_or_null("SystemIcon") != null, "System rooms must use the user's original vanilla icons")
_check(room.get_node("SystemRole").text == "SHIELDS", "Room floor must show its installed role")
_check(room.get_node("SystemRole").font == UiAssets.font("body") and ship.crew_nodes["model-test"].get_node("Name").font == UiAssets.font("body"), "Crew names and room roles must use the owner-local native FTL font")
_check(ship.crew_nodes["model-test"].get_node("Model").animation_state == "work", "Live native manning must drive working animation")
_check(ship.crew_nodes["model-test"].get_node("Model").arms[0].rotation.x > 0.7, "A crew actor first observed during pause must receive its native activity pose immediately")
_check(ship.door_nodes["10000"].get_meta("kind") == "door", "Native door must be a selectable VR target")
_check(ship.door_nodes["10000"].position.is_equal_approx(ship.pixel_point(door, 0.13)), "Door must use native pixel center and layout offsets")
_check(ship.drone_nodes["space-test"].position.is_equal_approx(ship.pixel_point(drone, 0.3)), "Drone must use its native position in its current space")
_check(is_equal_approx(ship.drone_nodes["space-test"].rotation.y, -deg_to_rad(3.0)), "Native small drone angles must remain degrees, without a radians discontinuity")
var base_door_signature := _signature(ship.door_nodes["10000"].get_node("Left"))
door["blast"] = 1
ship.apply_live({"rooms": [room_data], "doors": [door]})
var armored_door_signature := _signature(ship.door_nodes["10000"].get_node("Left"))
_check(armored_door_signature != base_door_signature, "Blast-door strength must visibly add armor instead of simply recoloring")
door["blast"] = 2
ship.apply_live({"rooms": [room_data], "doors": [door]})
_check(_signature(ship.door_nodes["10000"].get_node("Left")) != armored_door_signature, "Improved blast doors must show another distinct armor tier")
var improved_signature := _signature(ship.door_nodes["10000"].get_node("Left"))
door["blast"] = 3
ship.apply_live({"rooms": [room_data], "doors": [door]})
var strong_leaf: MeshInstance3D = ship.door_nodes["10000"].get_node("Left")
_check(_signature(strong_leaf) != improved_signature, "Crew-boosted native door strength must retain a subtle distinct rail pattern")
var door_bounds := strong_leaf.mesh.get_aabb()
_check(door_bounds.size.z <= 0.03001 and door_bounds.size.x <= 0.05901 and absf(strong_leaf.position.x) + door_bounds.size.x * 0.5 <= 0.0645, "Highest-tier closed leaves must fit the original jamb and stay shallow")
var hazard_point := {"x": 510, "y": 90, "damage": 80}
room_data["fire_tiles"] = [hazard_point]
room_data["breach_tiles"] = [{"x": 545, "y": 90, "damage": 20}]
room_data["fires"] = 1
ship.apply_live({"rooms": [room_data], "crew": [crew], "doors": [door], "drones": [drone]})
_check(ship.room_hazards.fire_points.size() == 1 and ship.room_hazards.breach_points.size() == 1, "Native hazards must create flame and breach geometry at actual tile positions")
_check(ship.room_hazards.fire_points[0].is_equal_approx(ship.pixel_point(hazard_point, 0.082)), "Fire visuals must share the native crew/room coordinate mapping")
_check(ship.room_hazards.flame_outer.multimesh.instance_count == 1, "Hazards must use one batched mesh rather than per-tile particles and lights")
ship._process(0.1)
var closed_x: float = ship.door_nodes["10000"].get_node("Left").position.x
door["open"] = true
ship.apply_live({"rooms": [room_data], "crew": [crew], "doors": [door], "drones": [drone]})
ship._process(0.2)
_check(ship.door_nodes["10000"].get_node("Left").position.x < closed_x - 0.05, "Native open state must slide doors apart")
ship.set_drop_target(room_id)
_check(room.get_node("Floor").material_override.albedo_color == Color(0.35, 0.95, 0.71), "Crew drop must highlight the target room")
ship.set_drop_target()
_check(room.get_node("Floor").material_override.albedo_color != Color(0.35, 0.95, 0.71), "Releasing crew must restore the room's oxygen/fire tint")
ship.simulation_paused = true
var before: float = ship.crew_nodes["model-test"].get_node("Model").animation_time
var hazard_before: float = ship.room_hazards.animation_time
var recoil_before: float = ship.weapon_nodes[0].recoil
ship._process(0.2)
_check(ship.crew_nodes["model-test"].get_node("Model").animation_time == before, "Native pause must freeze crew animations")
_check(ship.room_hazards.animation_time == hazard_before and ship.weapon_nodes[0].recoil == recoil_before, "Native pause must freeze fire, breach-air, and weapon presentation")
# Viewer facing is only deck yaw, so tilted/scaled tables keep actors upright.
ship.position = Vector3(2.0, 0.7, -1.0)
ship.rotation = Vector3(0.17, 0.7, -0.13)
ship.scale = Vector3(0.35, 0.4, 0.5)
var actor: Node3D = ship.crew_nodes["model-test"]
var actor_model: Node3D = actor.get_node("Model")
for state in ["work", "repair", "fight", "idle"]:
actor_model.set_state({"working": state == "work", "repairing": state == "repair", "fighting": state == "fight"})
var local_view := actor.position + Vector3(1.5, 3.2, 0.8)
ship.set_viewer_position(ship.to_global(local_view))
for i in range(90): ship._process(1.0 / 90.0)
var expected := atan2(-1.5, -0.8)
_check(absf(wrapf(actor_model.rotation.y - expected, -PI, PI)) < 0.014, "Paused %s crew must face the viewer in the transformed deck plane" % state)
_check(is_zero_approx(actor_model.rotation.x) and is_zero_approx(actor_model.rotation.z), "Viewer-facing crew must not pitch or roll as billboards")
_check(actor_model.animation_time == before, "Viewer updates must not advance native paused activity")
var yaw_before: float = actor_model.rotation.y
ship.set_viewer_position(ship.to_global(actor.position + Vector3(0, 4, 0)))
ship._process(0.1)
_check(actor_model.rotation.y == yaw_before, "A viewer directly over crew must not produce an unstable yaw")
actor_model.set_state({"running": true})
actor.set_meta("target", actor.position + Vector3(0.25, 0, 0))
ship._process(0.02)
_check(is_equal_approx(actor_model.rotation.y, -PI / 2.0), "Running crew must follow actual native movement instead of the viewer")
ship.rotation = Vector3.ZERO
ship.scale = Vector3.ONE
ship.apply_live({"rooms": [room_data], "room_systems": {}, "crew": [], "doors": [], "drones": []})
_check(room.get_node_or_null("SystemRole") == null, "Empty rooms must show no meaningless floor numbers")
_check(ship.drone_nodes.is_empty() and ship.door_nodes.is_empty(), "Expired native doors/drones must be removed")
_check(ship.room_hazards.fire_points.size() == 1, "Existing native fire tiles must remain visible while paused")
room_data["fire_tiles"] = []
room_data["breach_tiles"] = []
room_data["fires"] = 0
ship.apply_live({"rooms": [room_data]})
_check(ship.room_hazards.fire_points.is_empty() and ship.room_hazards.breach_points.is_empty(), "Native repair must remove hazards immediately, even during pause")
ship.position = Vector3(2, 0.7, -1)
ship.rotation.y = 0.7
ship.scale = Vector3(0.35, 0.4, 0.5)
var center := room.position
center.y = 0.0775
var floor_world := ship.to_global(center)
var ray_from := floor_world + Vector3.UP
var hit := ship.room_ray_hit(ray_from, Vector3.DOWN)
_check(not hit.is_empty() and hit.room_id == room_id, "Room picking must find the actual floor on rotated and scaled ships")
_check(hit.position.is_equal_approx(floor_world) and is_equal_approx(hit.distance, 1.0), "Room ray result must preserve WORLD distance and the visible floor height")
_check(ship.pixel_point(hit.pixel, 0.0775).is_equal_approx(center), "Room floor rays must map back to exact native ship pixels")
var bounds: Rect2 = ship.room_bounds[room_id]
var beyond := Vector3(bounds.end.x + 0.015 / ship.global_basis.x.length(), 0.0775, bounds.get_center().y)
var edge_from := ship.to_global(beyond) + Vector3.UP
_check(ship.room_ray_hit(edge_from, Vector3.DOWN).is_empty(), "Strict room picking must reject rays outside the ship")
var near_edge := ship.room_ray_hit(edge_from, Vector3.DOWN, 0.02)
_check(not near_edge.is_empty() and near_edge.room_id == room_id and near_edge.edge_margin, "Friendly drop tolerance must use a small WORLD edge margin")
_check(bounds.has_point(Vector2(ship.pixel_point(near_edge.pixel).x, ship.pixel_point(near_edge.pixel).z)), "Tolerant room drops must clamp native commands back inside the selected room")
_check(ship.room_ray_hit(edge_from + ship.global_basis.x.normalized() * 0.03, Vector3.DOWN, 0.02).is_empty(), "Room drop tolerance must reject positions beyond its WORLD margin")
_check(ship.room_ray_hit(ray_from, Vector3.RIGHT, 0.02).is_empty(), "Parallel floor rays must not pick unrelated rooms")
for asset in ["auto_assault", "fed_scout__fed_scout_pirate", "rebel_long", "energy_fighter_pirate"]:
var opponent := ShipModel.new()
root.add_child(opponent)
opponent.build(asset, true)
opponent.inspect_rooms = true
var id: int = opponent.room_nodes.keys()[0]
# Native geometry is authoritative, including enemy layout alignment
# and any layout x/y offsets already applied to ship pixel coordinates.
var native_shape := {"center": {"x": 110, "y": 160}, "a": 200, "b": 270}
opponent.apply_live({"rooms": [{"id": id, "visible": false, "oxygen": 0, "fire_tiles": [hazard_point], "breach_tiles": [hazard_point]}], "room_systems": {str(id): "weapons"}, "crew": [], "shield_shape": native_shape, "shield": 2})
var native_center: Vector3 = opponent.pixel_point(native_shape.center, opponent.shield_center.y)
_check(opponent.shield_center.is_equal_approx(native_center), "%s must use the native ellipse center, without adding image alignment twice" % asset)
_check(is_equal_approx(opponent.shield_radii.x, 200.0 / 35.0 * opponent.TILE * opponent.shield_fit_padding) and is_equal_approx(opponent.shield_radii.z, 270.0 / 35.0 * opponent.TILE * opponent.shield_fit_padding), "%s must derive its shield semiaxes from native geometry with measured visual padding" % asset)
_check(opponent.shield_fit_padding >= 1.035 and opponent.shield_fit_padding <= 1.12, "Shield footprint fitting must remain limited instead of hiding coordinate errors")
_check(opponent.room_nodes[id].get_node("SystemIcon").visible and opponent.room_nodes[id].get_node("SystemRole").visible, "Enemy utility icons must remain visible for targeting under sensor fog")
_check(opponent.room_hazards.fire_points.is_empty() and opponent.room_hazards.breach_points.is_empty(), "Hidden enemy rooms must not reveal hazard tile positions through sensor fog")
_check(opponent.room_hazards.flame_outer.multimesh.mesh == ship.room_hazards.flame_outer.multimesh.mesh, "Hazards across ships must reuse immutable mesh resources")
opponent.queue_free()
print("MODEL_TESTS ", "PASS" if failures == 0 else "FAIL %d" % failures, " species=8 animations=4 weapon_types=6 drone_families=10 deck_facing=paused+transformed door_tiers=4 hull_voxels=", ship.hull_voxel_count)
ship.queue_free()
await process_frame
quit(0 if failures == 0 else 1)
+70
View File
@@ -0,0 +1,70 @@
extends SceneTree
var failures := 0
var content_updates := 0
func check(condition: bool, message: String) -> void:
if not condition:
failures += 1
push_error(message)
func _initialize() -> void:
call_deferred("run")
func run() -> void:
var camera := Camera3D.new()
camera.position = Vector3(0, 0, 3)
camera.current = true
root.add_child(camera)
var surface: Node3D = load("res://scripts/hud_surface.gd").new()
surface.frame_file = "ui-test-frame.png"
root.add_child(surface)
surface.set_process(false)
surface.canvas.texture_changed.connect(func(): content_updates += 1)
var image := Image.create(1280, 720, false, Image.FORMAT_RGBA8)
image.fill(Color(0.2, 0.6, 0.3, 1.0))
image.save_png("res://local_game_data/ui-test-frame.png")
surface.canvas.poll_original_frame()
var texture: Texture2D = surface.canvas.original_frame
for i in range(4): await process_frame
await RenderingServer.frame_post_draw
var initial_updates := content_updates
check(surface.viewport.render_target_update_mode == SubViewport.UPDATE_ONCE, "HUD must use one-shot viewport rendering, rather than per-frame rendering")
surface.set_state({"paused": true})
surface.canvas.poll_original_frame()
check(content_updates == initial_updates, "Unchanged native PNGs and live state must not schedule another native HUD render")
image.fill(Color(0.7, 0.2, 0.3, 1.0))
image.save_png("res://local_game_data/ui-test-frame.png")
surface.canvas.poll_original_frame()
check(surface.canvas.original_frame == texture, "Native PNG updates must reuse the existing GPU texture")
check(surface.viewport.render_target_update_mode == SubViewport.UPDATE_ONCE, "Fresh native pixels must schedule one viewport render")
check(surface.canvas.frame_image.get_pixel(0, 0).r > 0.6, "The refreshed image must also update picking pixels")
for i in range(3): await process_frame
await RenderingServer.frame_post_draw
check(surface.viewport.get_texture().get_image().get_pixel(0,0).r > 0.6, "A second one-shot render must update the actual GPU viewport texture")
var before_crop := content_updates
surface.set_crop(Rect2(0, 0, 1280, 720), Vector2(2.85, 1.603125))
check(content_updates == before_crop, "An identical crop must not schedule another render of a static hand screen")
surface.set_crop(Rect2(300, 90, 650, 520), Vector2(1.02, 0.816))
check(content_updates == before_crop + 1, "A real native panel crop change must render the new bounds once")
var keyboard: Node3D = load("res://scripts/rename_keyboard.gd").new()
root.add_child(keyboard)
keyboard.set_entry({"active":true,"id":"ui-test","kind":"crew","text":"Dana","cursor":2})
check(keyboard.visible and keyboard.value_label.text == "Da|na", "Native cursor and current name must display in the VR keyboard")
for i in range(3): await process_frame
await RenderingServer.frame_post_draw
keyboard.viewport.get_texture().get_image().save_png("res://local_game_data/rename-keyboard.png")
var result: Dictionary = keyboard.activate({"op":"shift"})
check(result.is_empty() and not keyboard.upper, "Shift must change keyboard case without sending a game hotkey")
result = keyboard.activate({"op":"confirm"})
check(result.op == "confirm" and result.entry_id == "ui-test", "Done must submit the exact active native text field")
keyboard.set_entry({"active":false})
check(not keyboard.visible and keyboard.ray_hit(Vector3(0,0,1), Vector3.FORWARD).is_empty(), "An inactive keyboard must disappear and stop consuming controller rays")
surface.queue_free()
keyboard.queue_free()
await process_frame
print("UI_TESTS ", "PASS" if failures == 0 else "FAIL %d" % failures)
quit(0 if failures == 0 else 1)