Update gameplay feedback and repair portable VR launchers

This commit is contained in:
ElHombreAlex committed 2026-10-03 18:20:32 +02:00
1 parent 35cdf71054
commit 6fffde9dc3
36 files changed
+1498 -176

No files matched your search

+136
View File
@@ -0,0 +1,136 @@
extends SceneTree
const ShipModel = preload("res://scripts/ship_model.gd")
const Effects = preload("res://scripts/combat_effects.gd")
const UiAssets = preload("res://scripts/ftl_ui_assets.gd")
func _initialize() -> void:
call_deferred("_run")
func _run() -> void:
# Presentation fixtures only. No bridge, game process, input or save writes.
root.size = Vector2i(1400, 900)
var world := WorldEnvironment.new()
var environment := Environment.new()
environment.background_mode = Environment.BG_COLOR
environment.background_color = Color("101923")
environment.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR
environment.ambient_light_color = Color("d4e7f2")
environment.ambient_light_energy = 0.65
world.environment = environment
root.add_child(world)
var light := DirectionalLight3D.new()
light.rotation_degrees = Vector3(-50, -25, 0)
light.light_energy = 1.2
root.add_child(light)
var camera := Camera3D.new()
camera.projection = Camera3D.PROJECTION_ORTHOGONAL
camera.size = 1.15
camera.position = Vector3(0.1, 1.8, 1.1)
root.add_child(camera)
camera.look_at(Vector3.ZERO)
var ship := ShipModel.new()
root.add_child(ship)
ship.build("__damage_fixture__", true)
ship.inspect_rooms = true
ship.set_process(false)
var rooms: Array = []
for id in range(6):
rooms.append({"id": id, "visible": id != 3, "oxygen": 0 if id == 5 else 100,
"breach_tiles": [{"x": 175, "y": 35 if id == 4 else 105}] if id >= 4 else []})
ship.apply_live({"rooms": rooms, "room_systems": {"0": "shields", "1": "weapons", "2": "engines", "3": "oxygen"},
"system_status": [{"room_id": 0, "health": 4, "max_health": 4, "health_visible": true, "health_detail_visible": true},
{"room_id": 1, "health": 2, "max_health": 4, "repair_progress": 0.55, "health_visible": true, "health_detail_visible": true},
{"room_id": 2, "health": 0, "max_health": 2, "health_visible": true, "health_detail_visible": true},
{"room_id": 3, "health": 1, "max_health": 3, "health_visible": true, "health_detail_visible": false}]})
ship.room_hazards.animate(0.38)
var labels := Node3D.new()
root.add_child(labels)
for item in [["FULL", 0], ["REPAIRING", 1], ["DISABLED", 2], ["SENSOR FOG", 3], ["AIR ESCAPING", 4], ["VACUUM", 5]]:
var area: Area3D = ship.room_nodes[item[1]]
_world_label(labels, item[0], area.position + Vector3(0, 0.11, 0.0), 0.0012)
var title := _caption("SYSTEM CONDITION / NATIVE REPAIR / BREACHES", Vector2(46, 35), 34)
var footer := _caption("Native damage changes room and utility colours. Repaired systems clear them; fog hides breach locations.", Vector2(46, 840), 23)
await _capture("damage_rooms_fixture.png")
labels.hide()
title.text = "VANILLA CROSS BREACH / OXYGEN-DRIVEN AIR"
footer.text = "The opening stays at the native tile center. Escaping air stops when the room has no oxygen."
camera.size = 0.52
camera.position = ship.room_nodes[4].position + Vector3(0.20, 0.6, 0.6)
camera.look_at(ship.room_nodes[4].position)
await _capture("breach_cross_fixture.png")
ship.queue_free()
labels.queue_free()
await process_frame
var sender := ShipModel.new()
root.add_child(sender)
sender.build("__damage_fixture__", false)
sender.set_process(false)
sender.hide()
sender.position = Vector3(-1.5, 0, 0)
sender.scale = Vector3.ONE * 0.30
var effects := Effects.new()
root.add_child(effects)
effects.set_process(false)
var targets: Array[Node3D] = []
for i in range(3):
var receiver := ShipModel.new()
root.add_child(receiver)
receiver.build("__damage_fixture__", true)
receiver.set_process(false)
receiver.scale = Vector3.ONE * 0.30
receiver.position = Vector3((i - 1) * 0.76, 0, 0)
receiver.inspect_rooms = true
receiver.apply_live({"rooms": [{"id": 0, "visible": true}], "shield": 2 if i == 1 else 0})
targets.append(receiver)
_world_label(root, ["HULL HIT", "SHIELD HIT", "EVADED"][i], receiver.position + Vector3(0, 0.40, 0), 0.0018)
var point: Dictionary = {"x": 70, "y": 70}
effects.resolve_live({"projectile_id": "fixture-hull", "outcome": "hull", "target": point}, targets[0])
effects.resolve_live({"projectile_id": "fixture-shield", "outcome": "shield", "target": point}, targets[1])
effects.spawn_shot({"kind": "missile", "outcome": "pending", "projectile_id": "fixture-miss", "target": point}, sender, targets[2])
effects.update_live_projectiles([{"id": "fixture-miss", "progress": 0.93, "missed": true}])
effects._process(0.06)
camera.size = 1.45
camera.position = Vector3(0.30, 2.3, 2.2)
camera.look_at(Vector3(0, 0.1, 0))
title.text = "ACTUAL OUTCOMES / DISTINCT FEEDBACK"
footer.text = "Orange hull impact / blue shield absorption / passing missile and MISS. Evasion never creates an impact."
await _capture("miss_outcomes_fixture.png")
print("DAMAGE_VISUAL_CAPTURE PASS")
quit()
func _capture(filename: String) -> void:
for i in range(12):
await process_frame
await RenderingServer.frame_post_draw
var path := ProjectSettings.globalize_path("res://local_game_data/" + filename)
root.get_texture().get_image().save_png(path)
print("DAMAGE_VISUAL_CAPTURE ", path)
func _world_label(parent: Node, text: String, point: Vector3, pixel_size: float) -> void:
var label := Label3D.new()
label.text = text
label.font = UiAssets.font("body")
label.font_size = 32
label.pixel_size = pixel_size
label.modulate = Color("fff2c4")
label.outline_size = 4
label.billboard = BaseMaterial3D.BILLBOARD_ENABLED
label.no_depth_test = true
label.position = point
parent.add_child(label)
func _caption(text: String, point: Vector2, font_size: int) -> Label:
var label := Label.new()
label.text = text
label.position = point
label.add_theme_font_override("font", UiAssets.font("body"))
label.add_theme_font_size_override("font_size", font_size)
label.modulate = Color("fff2c4")
root.add_child(label)
return label
+39 -6
View File
@@ -16,21 +16,54 @@ from lab_settings import configure
PROJECT = Path(__file__).resolve().parents[1]
def load_config(path):
if not path.is_file():
raise ValueError('Missing local_game_data/launcher.json. Run SETUP.cmd, then follow docs/INSTALLATION.md.')
value = json.loads(path.read_text(encoding='utf-8-sig'))
if not isinstance(value, dict):
raise ValueError('launcher.json must contain a JSON object with lab, hooks and godot paths.')
return value
def preflight(config, desktop=False):
if os.name != 'nt':
raise ValueError('The live game bridge currently supports Windows only.')
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 not isinstance(config.get(name), str) or not config[name].strip():
raise ValueError(f'launcher.json needs a nonempty {name} path.')
if not Path(config[name]).is_absolute():
raise ValueError(f'Use an absolute path for {name} in launcher.json.')
valid = Path(config[name]).is_dir() if name == 'lab' else Path(config[name]).is_file()
if not valid: raise ValueError(f'Missing {name}: {config[name]}')
for module in ('frida', 'PIL', 'numpy', 'capstone'):
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 (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 marker.get('save_prefix') != 'ftlvr':
raise ValueError('The normal launcher requires an isolated lab with the ftlvr save prefix.')
manifest_path = PROJECT/'local_game_data/manifest.json'
if not manifest_path.is_file():
raise ValueError('Local game assets are missing. Run tools/extract_ftl.py on your owned original ftl.dat.')
manifest = json.loads(manifest_path.read_text(encoding='utf-8-sig'))
if not isinstance(manifest, dict) or not all(manifest.get(name) for name in ('ships', 'fonts', 'ui_assets')):
raise ValueError('Local extraction is incomplete. Run tools/extract_ftl.py again.')
hooks = json.loads(Path(config['hooks']).read_text(encoding='utf-8-sig'))
if not isinstance(hooks, dict):
raise ValueError('hooks.json must contain resolved local executable hook data.')
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']:
if not marker.get('activated') or not (lab/'FTLGame.exe').is_file() or hashlib.sha1((lab/'FTLGame.exe').read_bytes()).hexdigest() != hooks.get('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'
@@ -56,7 +89,7 @@ def main():
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())
config=load_config(args.config)
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)
+1 -1
View File
@@ -97,7 +97,7 @@ def decode_state_line(line):
result['projectiles'] = []
for side in ('player','enemy'):
if isinstance(result.get(side),dict):
for field in ('rooms','crew','weapons','doors','drones','system_status'):
for field in ('rooms','crew','weapons','doors','drones','drone_equipment','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'):
+2 -38
View File
@@ -7,7 +7,6 @@ copies, patches, launches or attaches to FTL. --check and --dry-run are read-onl
from __future__ import annotations
import argparse
import importlib.util
import json
import os
import shutil
@@ -61,46 +60,11 @@ def setup(dry_run: bool = False) -> None:
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)
from launch import load_config, preflight
result = preflight(load_config(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.")
+6
View File
@@ -0,0 +1,6 @@
@echo off
echo.
echo Launch stopped. Read the error above and docs\INSTALLATION.md or docs\TROUBLESHOOTING.md.
echo.
if not "%FTLVR_NO_PAUSE%"=="1" pause
exit /b 0
+9
View File
@@ -13,6 +13,15 @@ from run_bridge import (decode_state_line, translate_command, full_screen_captur
class BridgeTests(unittest.TestCase):
def test_empty_native_drone_equipment_does_not_erase_weapons(self):
weapons=[{'slot':0,'name':'MISSILES_2','title':'Artemis Missiles','ammo_cost':1},
{'slot':1,'name':'LASER_BURST_3','title':'Burst Laser Mark II','ammo_cost':0}]
raw={'protocol':2,'source':'hyperspace','ready':True,
'player':{'weapons':weapons,'drone_equipment':{},'crew':{},'drones':{}}}
normalized=decode_state_line('FTLVR_STATE '+json.dumps(raw))
self.assertEqual(normalized['player']['drone_equipment'],[])
self.assertEqual(normalized['player']['weapons'],weapons)
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))
+35 -1
View File
@@ -26,7 +26,7 @@ func _run() -> void:
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")
_check(scene.hud_surface.get_parent() == scene, "Gameplay HUD must support spatial placement without inheriting head motion")
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")
@@ -95,11 +95,45 @@ func _run() -> void:
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}])
var miss_count: int = effects.misses.size()
_check(miss_count > 0 and effects.misses.back()["node"].text == "MISS", "A native evasion must visibly say MISS without an impact")
effects.update_live_projectiles([{"id": "live-miss", "progress": 0.7, "missed": true}])
effects.resolve_live({"projectile_id": "live-miss", "outcome": "miss", "target": {"x": 80, "y": 170}}, enemy)
_check(effects.misses.size() == miss_count and effects.shots.size() == 1, "Miss snapshots and native outcomes must produce one cue and preserve a still-live passing projectile")
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")
_check(effects.impacts.is_empty() and enemy.shield_flash > 0.0, "A miss must not add a hull impact or reset an existing shield state")
effects.simulation_paused = true
var miss_age: float = effects.misses.back()["time"]
effects._process(0.5)
_check(effects.misses.back()["time"] == miss_age, "Native pause must freeze evasion feedback")
effects.simulation_paused = false
effects.update_live_projectiles([])
effects._process(0.3)
effects.update_live_projectiles([])
effects._process(1.1)
_check(effects.misses.is_empty(), "Evasion feedback must clear after its short presentation lifetime")
effects.resolve_live({"projectile_id": "already-dead-miss", "outcome": "miss", "target": {"x": 80, "y": 170}}, enemy)
_check(effects.misses.size() == 1 and effects.impacts.is_empty(), "Native misses that vanish between snapshots must still display feedback without invented damage")
effects.resolve_live({"projectile_id": "actual-hull", "outcome": "hull", "target": {"x": 80, "y": 170}}, enemy)
effects.resolve_live({"projectile_id": "actual-shield", "outcome": "shield", "target": {"x": 80, "y": 170}}, enemy)
_check(effects.impacts[-2]["outcome"] == "hull" and effects.impacts[-1]["outcome"] == "shield", "Hull and shield feedback must retain distinct actual native outcomes")
_check(effects.impacts[-2]["node"].material_override.albedo_color != effects.impacts[-1]["node"].material_override.albedo_color, "Hull and shield flashes must use visibly different colors")
effects._process(1.1)
# A projectile can fire, miss, and die between native snapshots. Its real
# outcome must end the unseen flight immediately, even while paused.
effects.simulation_paused = true
effects.spawn_shot({"kind": "laser", "outcome": "pending", "projectile_id": "gap-miss"}, player, enemy)
effects.resolve_live({"projectile_id": "gap-miss", "outcome": "miss", "target": {"x": 80, "y": 170}}, enemy)
effects.update_live_projectiles([])
_check(effects.shots.is_empty() and effects.misses.size() == 1 and effects.impacts.is_empty(), "Native missed fire absent from its first snapshot must clear immediately with one MISS cue and no invented hit")
effects.spawn_shot({"kind": "laser", "outcome": "pending", "projectile_id": "fresh-live-miss"}, player, enemy)
effects.resolve_live({"projectile_id": "fresh-live-miss", "outcome": "miss", "target": {"x": 80, "y": 170}}, enemy)
effects.update_live_projectiles([{"id": "fresh-live-miss", "progress": 0.75, "missed": true}])
_check(effects.shots.size() == 1 and effects.shots[0]["projectile_id"] == "fresh-live-miss", "A native miss before its first snapshot must preserve the projectile when the same snapshot still reports it alive")
effects.update_live_projectiles([])
effects.simulation_paused = false
effects._process(1.1)
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}])
+54
View File
@@ -118,8 +118,62 @@ func run() -> void:
panel.set_power(source_player)
await rendered()
check(powered_count.get_data() != panel.viewport.get_texture().get_image().get_region(Rect2i(537,206,50,15)).get_data(),"The visible power count must show native effective 1/1 instead of raw allocated 0/1")
var storm_player := source_player.duplicate(true)
storm_player.reactor = {"raw_available":4,"available":0,"usable_available":0,"usable_total":8,"total":8,"installed":12,"cap_loss":4,"storm_loss":4,"battery_available":2,"battery_total":2}
panel.set_power(storm_player)
var reactor_view: Dictionary = panel.reactor_display()
check(reactor_view.free == 0 and reactor_view.total == 8 and reactor_view.installed == 12 and reactor_view.storm_loss == 4,"Storm-disabled reactor bars must not be shown as usable free power")
check(reactor_view.battery_free == 2 and reactor_view.free == 0,"Battery availability must remain distinct from free reactor bars")
await rendered()
panel.viewport.get_texture().get_image().save_png("res://local_game_data/controller-system-power-storm.png")
panel.set_power(source_player)
var wheel: Node3D = load("res://scripts/shortcut_wheel.gd").new()
root.add_child(wheel)
var equipment_player := {"weapons":[{"slot":0,"name":"LASER_BURST_2","title":"Laser à impulsions II","kind":"laser","charge":1.0},{"slot":1,"name":"MISSILES_1","title":"Artemis","kind":"missile","missile_cost":1}],"drones":[{"slot":-1,"equipment_slot":0,"title":"Combat I","kind":"combat","x":4},{"slot":-1,"title":"Special actor","kind":"hacking"}]}
var equipment: Array = wheel.equipment_entries(equipment_player,1)
check(equipment.size() == 8 and equipment[0].label == "Laser à impulsions II" and equipment[1].detail == "1 MISSILE","Wheel slots must expose localized equipped names and actual ammo costs")
check(equipment[4].label == "Combat I" and equipment[4].key == 53 and not equipment[2].enabled and not equipment[5].enabled,"Equipment slots must preserve native keys and omit special-drone actors/empty slots")
var equipment_schema := equipment_player.duplicate(true)
equipment_schema.weapons[1].ammo_cost = 2
equipment_schema.drone_equipment = [{"slot":0,"title":"Defense II","kind":"defense"}]
var native_equipment: Array = wheel.equipment_entries(equipment_schema,1)
check(native_equipment[1].detail == "2 MISSILES" and native_equipment[4].label == "Defense II","Authoritative ammo_cost and separate installed-drone metadata must take priority over actor fallbacks")
var no_drones := equipment_player.duplicate(true)
no_drones.drone_equipment = {}
var empty_native_table: Array = wheel.equipment_entries(no_drones,0)
check(empty_native_table[0].enabled and empty_native_table[0].label == "Laser à impulsions II" and empty_native_table[1].enabled,"A native empty Lua drone table must not suppress equipped weapons or fail the wheel")
check(not empty_native_table[4].enabled and empty_native_table[4].label == "EMPTY","An explicit empty installed-drone table must not fall back to moving special actors")
no_drones.weapons = {}
no_drones.drone_equipment = null
check(not wheel.equipment_entries(no_drones,0)[0].enabled,"Empty or temporarily unavailable native equipment must produce empty slots without a typed-array error")
var native_path := "res://local_game_data/live_state.json"
var recorded_wheel: Array = []
if FileAccess.file_exists(native_path):
var native_state: Variant = JSON.parse_string(FileAccess.get_file_as_string(native_path))
if native_state is Dictionary and native_state.get("player", {}).get("weapons", []) is Array:
var live_weapons: Array = native_state.player.weapons
var live_wheel: Array = wheel.equipment_entries(native_state.player,int(native_state.get("drone_slots",0)))
recorded_wheel = live_wheel
for i in range(mini(4,live_weapons.size())):
check(live_wheel[i].enabled and live_wheel[i].label == live_weapons[i].get("title",live_weapons[i].get("name","")),"The recorded native player equipment must appear in the wheel")
wheel.set_entries(equipment,"WEAPONS\nDRONES")
revision = wheel.content_revision
equipment_player.weapons[0].charge = 8.5
equipment_player.drones[0].x = 15
wheel.set_entries(wheel.equipment_entries(equipment_player,1),"WEAPONS\nDRONES")
check(wheel.content_revision == revision,"Changing charge/positions must not redraw unchanged wheel equipment labels")
var long_name := "TrèsLongNomSansEspacePourUneArmeModifiée " + "With more translated words to clip"
var bounded: Array = wheel.bounded_lines(long_name)
check(bounded.size() <= 3 and str(bounded.back()).ends_with("..."),"Long localized equipment names must be clipped to bounded sector lines")
for line in bounded: check(load("res://scripts/ftl_ui_theme.gd").text_size(str(line),23).x <= 136.01,"Every wheel name line must fit its measured native-font width")
wheel.choose(Vector2(0,1))
wheel.visible = true
await rendered()
wheel.viewport.get_texture().get_image().save_png("res://local_game_data/controller-action-wheel-equipment.png")
if not recorded_wheel.is_empty():
wheel.set_entries(recorded_wheel,"WEAPONS\nDRONES")
await rendered()
wheel.viewport.get_texture().get_image().save_png("res://local_game_data/controller-action-wheel-native.png")
wheel.set_entries([{"label":"BURST LASER II","enabled":true},{"label":"ARTEMIS","enabled":true},{"label":"EMPTY","enabled":false},{"label":"EMPTY","enabled":false},{"label":"COMBAT DRONE","enabled":true},{"label":"BEAM DRONE","enabled":true}],"WEAPONS\nDRONES")
wheel.choose(Vector2(0,1))
revision = wheel.content_revision
+120
View File
@@ -0,0 +1,120 @@
extends SceneTree
const ShipModel = preload("res://scripts/ship_model.gd")
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 ship := ShipModel.new()
root.add_child(ship)
ship.build("__damage_fixture__", true)
ship.set_process(false)
ship.inspect_rooms = true
var id: int = ship.room_nodes.keys()[0]
var room := {"id": id, "visible": true, "oxygen": 100.0}
var system := {"room_id": id, "health": 4, "max_health": 4}
var state := {"rooms": [room], "room_systems": {str(id): "shields"}, "system_status": [system]}
var original_state := JSON.stringify(state)
ship.apply_live(state)
_check(JSON.stringify(state) == original_state, "Presentation must not mutate the native game snapshot")
var area: Area3D = ship.room_nodes[id]
var floor: MeshInstance3D = area.get_node("Floor")
var healthy_tint: Color = floor.material_override.albedo_color
_check(not area.has_node("SystemCondition"), "Healthy rooms must not contain system health bars or numbers")
system.health = 2
ship.apply_live(state)
var damaged_tint: Color = floor.material_override.albedo_color
_check(damaged_tint != healthy_tint, "Authoritative damage must still change the room colour after removing health bars")
_check(area.get_node("SystemRole").modulate == Color("ffad44"), "Partial damage must give the installed utility an amber cue")
system.repair_progress = 0.35
ship.apply_live(state)
_check(not area.has_node("SystemCondition"), "Partial repairs must not recreate floor bars or repair counters")
system.health = 0
ship.apply_live(state)
_check(floor.material_override.albedo_color != damaged_tint and area.get_node("SystemRole").modulate == Color("f26043"), "Destroyed systems must have a distinct red room and utility cue")
ship.simulation_paused = true
system.health = 3
ship.apply_live(state)
_check(floor.material_override.albedo_color != damaged_tint and area.get_node("SystemRole").modulate == Color("ffad44"), "Native repairs must update damage colour even during pause")
system.health = 4
ship.apply_live(state)
_check(floor.material_override.albedo_color == healthy_tint and area.get_node("SystemRole").modulate == Color("fff2c4"), "Full native repair must immediately clear every damage colour")
system.health_detail_visible = false
ship.apply_live(state)
_check(not area.has_node("SystemCondition"), "Revealed or hidden exact health must never create floor bars")
system.erase("health_detail_visible")
room.visible = false
system.health = 1
ship.apply_live(state)
_check(area.get_node("SystemRole").modulate == Color("fff2c4"), "Sensor fog must not reveal private health through icon colour")
_check(floor.material_override.albedo_color == Color(0.12, 0.14, 0.17), "Undisclosed enemy damage must retain the native fog floor tint")
system.health_visible = true
ship.apply_live(state)
_check(area.get_node("SystemRole").modulate == Color("ffad44"), "Native public damage may tint a hidden utility without revealing exact health")
var coarse_tint: Color = floor.material_override.albedo_color
system.health = 3
ship.apply_live(state)
_check(floor.material_override.albedo_color == coarse_tint, "Coarse public condition colours must not leak exact remaining enemy system levels")
system.health = 1
system.health_detail_visible = true
ship.apply_live(state)
_check(not area.has_node("SystemCondition"), "Even explicitly revealed enemy health must not recreate removed bars")
ship.inspect_rooms = false
ship.apply_live(state)
_check(not area.get_node("SystemRole").visible, "Closed enemy hull must hide the system utility label")
ship.inspect_rooms = true
room.visible = true
var tile := {"x": 35.0, "y": 35.0}
room.breach_tiles = [tile]
ship.apply_live(state)
var hazards: Node3D = ship.room_hazards
_check(hazards.breach_points.size() == 1 and hazards.breach_points[0].is_equal_approx(ship.pixel_point(tile, 0.082)), "Breach cross must use the actual native tile center")
_check(hazards.air_streams.multimesh.instance_count == 1, "An oxygenated native breach must emit escaping air")
var breach_mesh: Mesh = hazards.breaches.multimesh.mesh
var arrays: Array = breach_mesh.surface_get_arrays(0)
var vertices: PackedVector3Array = arrays[Mesh.ARRAY_VERTEX]
var colors: PackedColorArray = arrays[Mesh.ARRAY_COLOR]
var black_vertices := 0
for i in range(vertices.size()):
if colors[i].is_equal_approx(Color("020407")):
black_vertices += 1
_check(absf(vertices[i].x) <= 0.01751 or absf(vertices[i].z) <= 0.01751, "Black breach geometry must have a cross silhouette, with empty diagonal corners")
_check(black_vertices == 72 and breach_mesh.get_aabb().size.x < ShipModel.TILE and breach_mesh.get_aabb().size.z < ShipModel.TILE, "Breach opening must fit a native floor tile and use only two crossed black prisms")
var paused_time: float = hazards.animation_time
ship._process(0.5)
_check(hazards.animation_time == paused_time, "Paused breach air must not advance animation")
room.oxygen = 0.0
ship.apply_live(state)
_check(hazards.breach_points.size() == 1 and hazards.air_streams.multimesh.instance_count == 0, "A vacuum room must retain its breach but stop escaping air")
room.visible = false
ship.apply_live(state)
_check(hazards.breach_points.is_empty() and hazards.air_streams.multimesh.instance_count == 0, "System damage visibility must not reveal private breach locations under fog")
room.visible = true
room.breach_tiles = []
ship.apply_live(state)
_check(hazards.breaches.multimesh.instance_count == 0 and hazards.air_streams.multimesh.instance_count == 0, "Native breach repair must remove hole and air immediately during pause")
var other := ShipModel.new()
root.add_child(other)
other.build("__damage_fixture__", false)
other.set_process(false)
other.apply_live(state)
_check(not other.room_nodes[id].has_node("SystemCondition"), "Player rooms must also omit system health bars")
_check(other.room_hazards.breaches.multimesh.mesh == breach_mesh, "Breach crosses across ships must reuse one shared mesh")
state.system_status = []
ship.apply_live(state)
_check(area.get_node("SystemRole").modulate == Color("fff2c4") and not area.has_node("SystemCondition"), "Missing native health must clear stale damage colour without showing counters")
print("DAMAGE_VISUAL_TESTS ", "PASS" if failures == 0 else "FAIL %d" % failures)
ship.queue_free()
other.queue_free()
await process_frame
quit(0 if failures == 0 else 1)
+172
View File
@@ -0,0 +1,172 @@
extends SceneTree
const Anchor = preload("res://scripts/hud_anchor.gd")
var failures := 0
func _initialize() -> void:
call_deferred("run")
func check(value: bool, message: String) -> void:
if not value:
failures += 1
push_error(message)
func _check_clearance(scene: Node3D, message: String) -> void:
var ship := Anchor.transformed_bounds(scene._player_hud_local_bounds(), scene.player_ship.global_transform)
var panel := Anchor.panel_bounds(scene.hud_surface.global_transform, scene.hud_surface.surface_size)
check(not Anchor.overlaps_ship(scene.hud_surface.global_transform, scene.hud_surface.surface_size, scene.player_ship.global_transform, scene._player_hud_local_bounds(), Anchor.CLEARANCE - 0.001), message)
if scene.hud_anchor.blocked:
check(panel.position.y >= ship.end.y + Anchor.CLEARANCE - 0.001, "A blocked HUD must stop entirely above the transformed ship/shield volume")
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)
scene.hud_surface.set_process(false)
scene.demo_mode = false
scene.xr_active = true
scene.origin.transform = Transform3D.IDENTITY
scene.camera.transform = Transform3D(Basis.IDENTITY, Vector3(0, 1.7, 1.3))
scene.hud_state = {"ready": true, "ui_mode": "game", "paused": true}
scene._recenter()
scene._update_hud()
scene._position_hud(0.0, true)
check(scene.hud_surface.get_parent() == scene and scene.gameplay_hud, "Gameplay HUD must permit collision-clamped headset-relative placement")
check(scene.hud_surface.source_rect == Rect2(0, 0, 872, 510), "Gameplay must retain upper status and complete crew roster while excluding the entire bottom HUD")
check(is_equal_approx(scene.hud_surface.global_basis.x.length(), 1.0) and is_equal_approx(scene.hud_surface.surface_size.x, 1.10), "HUD readability must use fixed world metres, independent of tabletop scale")
check(scene.pause_label.get_parent() == scene.hud_surface and scene.pause_label.visible and scene.pause_label.position.y > scene.hud_surface.surface_size.y / 2, "Pause must remain visible above the compact HUD after the lower UI is removed")
check(scene.tracking_label.get_parent() == scene.camera and scene.tracking_label.position.x > 0 and scene.tracking_label.position.y > -.5, "Modifier/controller status must stay accessible at the lower right of the view")
_check_clearance(scene, "Initial HUD must clear the hull and shields")
check(scene.hud_surface.global_position.z < scene.camera.global_position.z, "Initial HUD should remain in front of the headset")
# Move the table out of the viewer's path. The head-relative HUD must follow
# position, pitch, yaw and roll instead of staying anchored to that table.
scene.tabletop_root.position = Vector3(8, 4, 8)
scene.hud_anchor.reset()
scene._position_hud(0.0, true)
check(not scene.hud_anchor.blocked and scene.hud_surface.global_position.is_equal_approx(scene.camera.get_camera_transform() * Anchor.HEAD_OFFSET), "Empty space must use the headset-relative pose even if a distant ship is higher")
scene.camera.position += Vector3(.02, .01, .01)
scene.camera.rotation = Vector3(-.2, .25, .12)
scene._position_hud(1.0 / 90)
check(scene.hud_surface.global_position.is_equal_approx(scene.camera.get_camera_transform() * Anchor.HEAD_OFFSET), "Unobstructed HUD must follow headset motion immediately")
check(scene.hud_surface.global_basis.is_equal_approx(scene.camera.get_camera_transform().basis), "Unobstructed HUD rotation must preserve headset-relative framing")
scene.camera.rotation = Vector3.ZERO
scene._recenter()
scene.camera.rotation.x = -.85
scene._position_hud(0.0, true)
check(scene.hud_anchor.blocked and scene.hud_anchor.lift > 0, "Looking down toward the ship must engage the HUD height stop")
_check_clearance(scene, "Looking down must not tunnel the HUD through the ship")
var clearance_lift: float = scene.hud_anchor.lift
scene.camera.rotation.y = PI
scene._position_hud(1.0 / 90)
check(not scene.hud_anchor.blocked and scene.hud_anchor.lift > 0 and scene.hud_anchor.lift < clearance_lift, "Turning away must release the height stop smoothly")
for frame in range(180):
scene._position_hud(1.0 / 90)
check(scene.hud_anchor.lift == 0 and scene.hud_surface.global_position.is_equal_approx(scene.camera.get_camera_transform() * Anchor.HEAD_OFFSET), "Once clear, the HUD must return fully to its headset anchor")
scene.camera.rotation = Vector3.ZERO
scene._recenter()
for scale_value in [.2, .35, .8, 1.5]:
for rotation_value in [Vector3.ZERO, Vector3(0, 1.2, 0), Vector3(.4, .7, .3)]:
scene.tabletop_root.basis = Basis.from_euler(rotation_value).scaled(Vector3.ONE * scale_value)
for height in [-.6, .0, .8]:
scene.tabletop_root.position += Vector3(.3, height, -.1)
for frame in range(10):
scene._position_hud(1.0 / 90)
_check_clearance(scene, "Scaled/rotated/grabbed ship must never intersect the HUD during smoothing")
check(scene.hud_surface.global_transform.is_finite(), "Extreme placement fallback must stay finite")
# Cropping changes geometry only, preserving every retained native pixel.
var image := Image.create(1280, 720, false, Image.FORMAT_RGBA8)
image.fill(Color.WHITE)
scene.hud_surface.canvas.frame_image = image
for native in [Vector2i(35, 30), Vector2i(580, 50), Vector2i(50, 470)]:
var world: Vector3 = scene.hud_surface.pixel_world(native)
var normal: Vector3 = scene.hud_surface.global_basis.z.normalized()
check(scene.hud_surface.ray_pixel(world + normal, -normal) == native, "Top/roster ray picks must preserve original native coordinates under rotation")
var removed_world: Vector3 = scene.hud_surface.pixel_world(Vector2i(400, 670))
check(scene.hud_surface.ray_pixel(removed_world + scene.hud_surface.global_basis.z, -scene.hud_surface.global_basis.z) == null, "Removed lower HUD must not intercept pointing at the world")
var event_crop: Rect2 = scene.event_surface.source_rect
var panel_crop: Rect2 = scene.world_surface.source_rect
scene.hud_state.event_open = true
scene.hud_state.panel_open = true
scene._sync_hud_layout()
check(scene.gameplay_hud and scene.event_surface.source_rect == event_crop and scene.world_surface.source_rect == panel_crop, "Gameplay top crop must not alter full native dialog/store/ship panel content")
scene.hud_state = {"ready": false, "ui_mode": "menu"}
scene._sync_hud_layout()
scene._position_hud(0.0)
check(not scene.gameplay_hud and scene.hud_surface.source_rect == Rect2(0, 0, 1280, 720), "Main menus must retain the entire original native screen")
var expected: Vector3 = scene.camera.get_camera_transform() * Vector3(0, -.16, -1.8)
check(scene.hud_surface.global_position.is_equal_approx(expected), "Full main menus must retain the original viewer-relative readable framing")
scene.xr_active = false
for mode in ["map_open", "tactical"]:
scene.hud_state = {"ready": true, "ui_mode": "game", mode: true}
scene._sync_hud_layout()
check(not scene.gameplay_hud and scene.hud_surface.source_rect.size == Vector2(1280, 720), "Desktop native map/Tactical display must preserve full content and picking")
scene.xr_active = true
scene.hud_state = {"ready": true, "ui_mode": "game", "tactical": true}
scene._sync_hud_layout()
check(scene.gameplay_hud, "VR Tactical screen must retain the headset HUD while its hand screen shows full Tactical content")
if DisplayServer.get_name() != "headless":
await _capture(scene)
scene.queue_free()
print("HUD_LAYOUT_TESTS ", "PASS" if failures == 0 else "FAIL %d" % failures)
quit(0 if failures == 0 else 1)
func rendered() -> void:
await process_frame
await RenderingServer.frame_post_draw
func _capture(scene: Node3D) -> void:
scene.camera.rotation.x = -.28
scene._recenter()
scene._position_hud(0.0, true)
scene.pause_label.visible = false
scene.hud_surface.canvas.use_original_frame = false
# Color bands make a removed lower HUD an objective GPU check, and retained
# roster/native positions independently test the crop rather than its math.
var image := Image.create(1280, 720, false, Image.FORMAT_RGBA8)
image.fill(Color.TRANSPARENT)
image.fill_rect(Rect2i(0, 0, 872, 100), Color.GREEN)
image.fill_rect(Rect2i(0, 150, 100, 340), Color.BLUE)
image.fill_rect(Rect2i(0, 550, 1280, 170), Color.RED)
scene.hud_surface.canvas._accept_frame(image, "hud-layout-fixture")
await rendered()
var output: Image = scene.hud_surface.viewport.get_texture().get_image()
check(output.get_pixel(600, 50).g > .8 and output.get_pixel(30, 650).b > .8, "GPU top crop must preserve both upper status and the long crew roster")
var red_pixels := 0
for y in range(0, output.get_height(), 5):
for x in range(0, output.get_width(), 5):
var pixel := output.get_pixel(x, y)
if pixel.r > .8 and pixel.g < .2 and pixel.b < .2: red_pixels += 1
check(red_pixels == 0, "No lower HUD pixels may leak into the cropped GPU texture")
scene.hud_state = {"ready": false, "ui_mode": "menu"}
scene._sync_hud_layout()
await rendered()
output = scene.hud_surface.viewport.get_texture().get_image()
check(output.get_pixel(600, 650).r > .8, "Full native menus must retain their lower content after returning from gameplay crop")
scene.hud_state = {"ready": true, "ui_mode": "game"}
scene._sync_hud_layout()
scene._position_hud(0.0, true)
# Prefer an immutable native QA snapshot so a concurrent bridge frame cannot
# change the fixture between its load and visual inspection.
var native_path := "res://local_game_data/speed-tactical-return-hud_frame.png"
if FileAccess.file_exists(native_path):
var native := Image.new()
native.load_png_from_buffer(FileAccess.get_file_as_bytes(native_path))
scene.hud_surface.canvas._accept_frame(native, "hud-layout-owned-native")
await rendered()
scene.hud_surface.viewport.get_texture().get_image().save_png("res://local_game_data/hud-top-spatial-native.png")
root.get_texture().get_image().save_png("res://local_game_data/hud-top-spatial-scene.png")
# The fallback/demo uses the same crop; bottom weapon/reactor rows stay out.
scene.hud_surface.canvas.original_frame = null
scene.hud_surface.canvas.set_state({"crew": [{"name": "Roster test", "health": 100}], "paused": false})
scene.hud_surface.canvas.request_redraw()
await rendered()
output = scene.hud_surface.viewport.get_texture().get_image()
output.save_png("res://local_game_data/hud-top-spatial-demo.png")
check(output.get_pixel(30, 690).a < .1, "Fallback/demo bottom power/reactor must also disappear under the native crop")
+34 -2
View File
@@ -102,7 +102,10 @@ func run() -> void:
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")
scene.hud_state = {"ready":true,"ui_mode":"game"}
scene._sync_hud_layout()
scene._position_hud(0.0, true)
check(is_equal_approx(scene.nav_panel.scale.x, 0.6) and scene.hud_surface.get_parent()==scene and scene.hud_surface.source_rect==scene.GAMEPLAY_HUD_RECT,"Gameplay HUD must retain native upper/crew pixels, exclude the bottom strip and use independent world placement")
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")
@@ -110,8 +113,10 @@ func run() -> void:
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.hud_state = {"ready":true,"ui_mode":"game","navigation":{"jump":true},"player":{"weapons":[{"slot":0,"name":"LASER_BURST_1"}],"systems":{"shields":true}},"drone_slots":1}
scene.nav_page = "navigation"
scene._refresh_navigation()
check(scene._navigation_entries().has(["SYSTEM POWER", "page:power"]),"System Power must have a direct Navigation entry")
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
@@ -133,6 +138,26 @@ func run() -> void:
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")
var saved_state: Dictionary = scene.hud_state.duplicate(true)
scene.hud_state = {"ready":true,"ui_mode":"game"}
scene.pending_page = ""
scene.nav_page = "navigation"
scene._toggle_power_page()
check(scene.nav_page=="power","Dpad Up action must open System Power")
scene._toggle_power_page()
check(scene.nav_page=="navigation","Dpad Up action must close System Power")
scene.hud_state.ui_mode = "menu"
scene._toggle_power_page()
check(scene.nav_page=="navigation","Dpad Up must not open unusable Power controls in a main menu")
scene.hud_state = {"ready":true,"ui_mode":"screen","map_open":true}
scene.wheel_committed = false
scene.wheel.visible = true
scene.wheel.set_entries([{"key":49,"enabled":true}],"WEAPONS")
scene.wheel.selected = 0
command_count = scene.commands.size()
scene._commit_wheel()
check(scene.commands.size()==command_count and not scene.wheel.visible,"Opening a native map must cancel equipment wheel commits")
scene.hud_state = saved_state
scene.event_surface.visible = true
scene.event_surface.canvas.frame_image = image
scene.event_surface.source_rect = Rect2(313,138,650,390)
@@ -160,6 +185,13 @@ func run() -> void:
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
scene.nav_page = "power"
scene.hud_state = {"ready":true,"ui_mode":"screen","panel_open":true,"blocking_ui":true}
scene.last_hover_time = -1.0
command_count = scene.commands.size()
scene._hover_ui(Vector3(0,0,1),Vector3.FORWARD)
check(scene.commands.size()==command_count+1 and scene.commands.back().action=="ui_move","Native window hover must remain available while System Power is selected")
scene.nav_page = "navigation"
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)
+145
View File
@@ -0,0 +1,145 @@
"""Read-only launch readiness and Windows wrapper regression checks; no FTL needed."""
import hashlib
import json
import os
import shutil
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
import launch
@unittest.skipUnless(os.name == "nt", "The live launcher is Windows only")
class LaunchSetupTests(unittest.TestCase):
def setUp(self):
self.temporary = tempfile.TemporaryDirectory()
self.root = Path(self.temporary.name) / "FTL checkout with spaces"
self.root.mkdir()
self.lab = self.root / "separate lab"
self.lab.mkdir()
self.source = self.root / "owned original"
self.source.mkdir()
(self.source / "ftl.dat").write_bytes(b"synthetic archive placeholder")
(self.lab / "FTLGame.exe").write_bytes(b"synthetic executable placeholder")
(self.lab / "Hyperspace.dll").write_bytes(b"synthetic loader placeholder")
self.marker = {"source_game": str(self.source), "activated": True, "save_prefix": "ftlvr"}
self.write_marker()
names = ("GetShiftState", "GetCtrlState", "ForceAutofireFlag", "OnTextInput",
"OnTextEvent", "TextInputOnRender", "TextInputStart", "CommandGuiRenderStatic",
"CommandGuiRenderPause", "TabbedWindowOnRender", "ChoiceBoxOnRender",
"MouseControlOnRender", "StarMapOnRender")
self.hooks = self.lab / "hooks.json"
self.hooks.write_text(json.dumps({"rvas": dict.fromkeys(names, 1),
"sha1": hashlib.sha1((self.lab / "FTLGame.exe").read_bytes()).hexdigest()}))
self.godot = self.root / "godot.exe"
self.godot.write_bytes(b"synthetic renderer placeholder")
self.local = self.root / "local_game_data"
self.local.mkdir()
self.manifest = self.local / "manifest.json"
self.manifest.write_text(json.dumps({"ships": ["test"], "fonts": 1, "ui_assets": 1}))
self.config = {"lab": str(self.lab), "hooks": str(self.hooks), "godot": str(self.godot)}
self.project_patch = patch.object(launch, "PROJECT", self.root)
self.project_patch.start()
self.packages_patch = patch.object(launch.importlib.util, "find_spec", return_value=object())
self.packages_patch.start()
def tearDown(self):
self.packages_patch.stop()
self.project_patch.stop()
self.temporary.cleanup()
def write_marker(self):
(self.lab / "ftlvr-lab.json").write_text(json.dumps(self.marker))
def test_shared_preflight_accepts_complete_desktop_setup(self):
self.assertEqual(launch.preflight(self.config, desktop=True)["mode"], "desktop")
def test_missing_configuration_explains_setup(self):
with self.assertRaisesRegex(ValueError, "Run SETUP.cmd"):
launch.load_config(self.local / "launcher.json")
def test_config_accepts_utf8_bom(self):
path = self.local / "launcher.json"
path.write_text(json.dumps(self.config), encoding="utf-8-sig")
self.assertEqual(launch.load_config(path), self.config)
def test_blank_or_relative_configuration_is_rejected(self):
for path, message in (("", "nonempty"), ("godot.exe", "absolute")):
with self.subTest(path=path), self.assertRaisesRegex(ValueError, message):
launch.preflight(dict(self.config, godot=path), desktop=True)
def test_game_and_renderer_need_correct_path_types(self):
with self.assertRaisesRegex(ValueError, "Missing godot"):
launch.preflight(dict(self.config, godot=str(self.root)), desktop=True)
def test_missing_or_partial_assets_prevent_game_launch(self):
self.manifest.unlink()
with self.assertRaisesRegex(ValueError, "Local game assets are missing"):
launch.preflight(self.config, desktop=True)
self.manifest.write_text(json.dumps({"ships": ["test"], "fonts": 0, "ui_assets": 1}))
with self.assertRaisesRegex(ValueError, "extraction is incomplete"):
launch.preflight(self.config, desktop=True)
def test_qa_save_prefix_is_rejected_by_normal_launcher(self):
self.marker["save_prefix"] = "testvr"
self.write_marker()
with self.assertRaisesRegex(ValueError, "ftlvr save prefix"):
launch.preflight(self.config, desktop=True)
def test_click_wrappers_report_unconfigured_checkout_without_hanging(self):
checkout = self.root / "unconfigured clone"
checkout.mkdir()
(checkout / "tools").mkdir()
actual_project = Path(__file__).resolve().parents[1]
helper = actual_project / "tools/show_launcher_error.cmd"
shutil.copyfile(helper, checkout / "tools/show_launcher_error.cmd")
for name in ("RUN-VR.cmd", "RUN-DESKTOP.cmd", "CHECK-SETUP.cmd"):
shutil.copyfile(actual_project / name, checkout / name)
environment = dict(os.environ, FTLVR_NO_PAUSE="1")
result = subprocess.run([os.environ.get("COMSPEC", "cmd.exe"), "/d", "/c", name],
cwd=checkout, env=environment, capture_output=True, text=True, timeout=10)
self.assertEqual(result.returncode, 1, name)
self.assertIn("SETUP.cmd", result.stdout, name)
self.assertIn("Launch stopped", result.stdout, name)
def test_setup_rejects_microsoft_store_alias(self):
checkout = self.root / "store alias clone"
checkout.mkdir()
(checkout / "tools").mkdir()
actual_project = Path(__file__).resolve().parents[1]
for name in ("SETUP.cmd", "tools/show_launcher_error.cmd"):
shutil.copyfile(actual_project / name, checkout / name)
alias = checkout / "fake LocalAppData/Microsoft/WindowsApps"
alias.mkdir(parents=True)
(alias / "python.exe").touch()
environment = dict(os.environ, FTLVR_NO_PAUSE="1", LOCALAPPDATA=str(checkout / "fake LocalAppData"),
PATH=str(Path(os.environ["SystemRoot"]) / "System32") + os.pathsep + str(alias))
environment.pop("FTLVR_PYTHON", None)
result = subprocess.run([os.environ.get("COMSPEC", "cmd.exe"), "/d", "/c", "SETUP.cmd"],
cwd=checkout, env=environment, capture_output=True, text=True, timeout=10)
self.assertEqual(result.returncode, 1)
self.assertIn("Microsoft Store alias is not a Python installation", result.stdout)
def test_setup_accepts_explicit_python_and_forwards_dry_run(self):
checkout = self.root / "explicit Python clone"
checkout.mkdir()
(checkout / "tools").mkdir()
actual_project = Path(__file__).resolve().parents[1]
for name in ("SETUP.cmd", "tools/setup_environment.py", "tools/show_launcher_error.cmd"):
shutil.copyfile(actual_project / name, checkout / name)
environment = dict(os.environ, FTLVR_NO_PAUSE="1", FTLVR_PYTHON=sys.executable)
result = subprocess.run([os.environ.get("COMSPEC", "cmd.exe"), "/d", "/c", "SETUP.cmd --dry-run"],
cwd=checkout, env=environment, capture_output=True, text=True, timeout=10)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
self.assertIn("Dry run complete", result.stdout)
self.assertFalse((checkout / ".venv").exists())
self.assertFalse((checkout / "local_game_data").exists())
if __name__ == "__main__":
unittest.main()