mirror of
https://github.com/ElHombreAlex/FTL-Faster-Than-Light-VR.git
synced 2026-10-06 01:00:06 +02:00
V0.1
First publishable version of the mod
This commit is contained in:
commit
35cdf71054
112 files changed
+18125
No files matched your search
@@ -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
|
||||
@@ -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()
|
||||
@@ -0,0 +1 @@
|
||||
uid://uxghhh678b68
|
||||
@@ -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()
|
||||
@@ -0,0 +1 @@
|
||||
uid://bm17rm3w3bb0g
|
||||
@@ -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()
|
||||
@@ -0,0 +1 @@
|
||||
uid://w2lkox2jrhi0
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://dmh8m7mtywriy
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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})
|
||||
@@ -0,0 +1 @@
|
||||
uid://dsrpbn3l2ka44
|
||||
@@ -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)
|
||||
@@ -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
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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))
|
||||
@@ -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()
|
||||
@@ -0,0 +1 @@
|
||||
uid://bwpyp25yh1tmy
|
||||
@@ -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))
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://dtdhv1evsbxrk
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://b2jy3xjno2on2
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://sr4hnfgun5is
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
Reference in new issue
Block a user