mirror of
https://github.com/ElHombreAlex/FTL-Faster-Than-Light-VR.git
synced 2026-10-06 03:00:06 +02:00
Fix Flagship visuals, beam aiming, results and Frame controls
This commit is contained in:
1 parent
e857f4eee9
commit
cf9cfca866
27 files changed
+1084
-96
No files matched your search
@@ -0,0 +1,92 @@
|
||||
extends SceneTree
|
||||
|
||||
const ShipModel = preload("res://scripts/ship_model.gd")
|
||||
const CombatEffects = preload("res://scripts/combat_effects.gd")
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
call_deferred("_run")
|
||||
|
||||
|
||||
func _run() -> void:
|
||||
root.size = Vector2i(1440, 900)
|
||||
var output := "res://local_game_data/miss_capture"
|
||||
for argument in OS.get_cmdline_user_args():
|
||||
if argument.begins_with("--output="):
|
||||
output = argument.trim_prefix("--output=")
|
||||
output = ProjectSettings.globalize_path(output)
|
||||
DirAccess.make_dir_recursive_absolute(output)
|
||||
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("101b27")
|
||||
settings.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR
|
||||
settings.ambient_light_color = Color("d4e7f2")
|
||||
settings.ambient_light_energy = 0.6
|
||||
environment.environment = settings
|
||||
world.add_child(environment)
|
||||
var light := DirectionalLight3D.new()
|
||||
light.rotation_degrees = Vector3(-45, -20, 0)
|
||||
light.light_energy = 1.4
|
||||
world.add_child(light)
|
||||
var player := ShipModel.new()
|
||||
var enemy := ShipModel.new()
|
||||
world.add_child(player)
|
||||
world.add_child(enemy)
|
||||
player.build("kestral", false)
|
||||
enemy.build("rebel_squat", true)
|
||||
for ship in [player, enemy]:
|
||||
ship.set_process(false)
|
||||
ship.set_shields(0)
|
||||
player.position = Vector3(-1.8, 0, 0)
|
||||
enemy.position = Vector3(1.7, 0, 0)
|
||||
enemy.rotation.y = PI / 2.0 if int(enemy.layout_data.get("vertical", 0)) != 0 else PI
|
||||
var camera := Camera3D.new()
|
||||
camera.projection = Camera3D.PROJECTION_ORTHOGONAL
|
||||
camera.size = 6.0
|
||||
world.add_child(camera)
|
||||
camera.position = Vector3(1.3, 4.8, 5.2)
|
||||
camera.look_at(Vector3(1.3, 0, 0))
|
||||
var effects := CombatEffects.new()
|
||||
world.add_child(effects)
|
||||
effects.set_process(false)
|
||||
# Native-schema fixtures expose a missed flight independently of any game
|
||||
# launch. Only the input snapshot values drive progress and disappearance.
|
||||
effects.spawn_shot({"kind": "laser", "outcome": "pending", "projectile_id": "capture-miss", "target_room": 0}, player, enemy)
|
||||
effects.update_live_projectiles([{"id": "capture-miss", "progress": 0.76}])
|
||||
effects._process(0.1)
|
||||
var proof: Dictionary = {}
|
||||
proof["before"] = effects.shots[0]["node"].global_position
|
||||
await _capture(output, "01_before_miss")
|
||||
effects.resolve_live({"projectile_id": "capture-miss", "outcome": "miss"}, enemy)
|
||||
effects.update_live_projectiles([{"id": "capture-miss", "progress": 0.94, "missed": true}])
|
||||
effects._process(0.1)
|
||||
proof["passed"] = effects.shots[0]["node"].global_position
|
||||
await _capture(output, "02_passing_miss")
|
||||
effects.update_live_projectiles([{"id": "capture-miss", "progress": 0.5, "missed": true}])
|
||||
effects._process(0.1)
|
||||
proof["reordered"] = effects.shots[0]["node"].global_position
|
||||
await _capture(output, "03_reordered_stays_forward")
|
||||
effects.update_live_projectiles([{"id": "capture-miss", "progress": 1.3, "missed": true}])
|
||||
effects._process(0.1)
|
||||
proof["beyond"] = effects.shots[0]["node"].global_position
|
||||
await _capture(output, "04_beyond_target")
|
||||
effects.update_live_projectiles([])
|
||||
effects._process(1.1)
|
||||
proof["remaining_shots"] = effects.shots.size()
|
||||
proof["impacts"] = effects.impacts.size()
|
||||
await _capture(output, "05_native_absence")
|
||||
var file := FileAccess.open(output.path_join("proof.json"), FileAccess.WRITE)
|
||||
file.store_string(JSON.stringify(proof, "\t"))
|
||||
print("MISS_CAPTURE PASS snapshots=5 remaining_shots=", effects.shots.size(), " impacts=", effects.impacts.size(), " output=", output)
|
||||
quit(0)
|
||||
|
||||
|
||||
func _capture(output: String, name: String) -> void:
|
||||
for i in range(6):
|
||||
await process_frame
|
||||
await RenderingServer.frame_post_draw
|
||||
var result := root.get_texture().get_image().save_png(output.path_join(name + ".png"))
|
||||
assert(result == OK, "Miss capture must save its rendered frame")
|
||||
+1
-1
@@ -61,7 +61,7 @@ def preflight(config, desktop=False):
|
||||
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','MenuScreenOnRender','OptionsScreenOnRender')):
|
||||
if not all(name in hooks.get('rvas',{}) for name in ('CommandGuiRenderStatic','CommandGuiRenderPause','TabbedWindowOnRender','ChoiceBoxOnRender','MouseControlOnRender','StarMapOnRender','MenuScreenOnRender','OptionsScreenOnRender','CombatControlRenderTarget')) or not all(name in hooks.get('offsets',{}) for name in ('gui_game_over','focus_window_open')):
|
||||
raise ValueError('Regenerate lab hooks.json with tools/resolve_hooks.py for live native HUD capture')
|
||||
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')
|
||||
|
||||
+16
-2
@@ -80,7 +80,8 @@ def resolve(executable: Path, signatures: Path) -> dict:
|
||||
for class_name,method_names in {'CommandGui':('RenderStatic','RenderPause'),
|
||||
'TabbedWindow':('OnRender',),'ChoiceBox':('OnRender',),
|
||||
'MouseControl':('OnRender',),'StarMap':('OnRender',),
|
||||
'MenuScreen':('OnRender',),'OptionsScreen':('OnRender',)}.items():
|
||||
'MenuScreen':('OnRender',),'OptionsScreen':('OnRender',),
|
||||
'CombatControl':('RenderTarget',)}.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
|
||||
@@ -113,6 +114,18 @@ def resolve(executable: Path, signatures: Path) -> dict:
|
||||
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')
|
||||
game_over_loop=method_address('GameOver','OnLoop')
|
||||
game_over_offsets=[]
|
||||
for index,instruction in enumerate(instructions):
|
||||
if instruction.mnemonic=='call' and instruction.op_str==hex(game_over_loop):
|
||||
previous=instructions[index-1]
|
||||
match=re.fullmatch(r'ecx, \[ebx \+ (0x[0-9a-f]+)\]',previous.op_str)
|
||||
if previous.mnemonic=='lea' and match: game_over_offsets.append(int(match.group(1),16))
|
||||
if game_over_offsets!=[0x1e88]: raise ValueError('CommandGui GameOver offset changed')
|
||||
close=method_address('FocusWindow','Close')
|
||||
first=next(decoder.disasm(code[close-rva:close-rva+8],close))
|
||||
if first.mnemonic!='mov' or first.op_str!='byte ptr [ecx + 4], 0':
|
||||
raise ValueError('Native FocusWindow open flag 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):
|
||||
@@ -130,7 +143,8 @@ def resolve(executable: Path, signatures: Path) -> dict:
|
||||
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,
|
||||
offsets={'app_gui':8,'gui_game_over':game_over_offsets[0],'focus_window_open':4,
|
||||
'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.
|
||||
|
||||
+51
-6
@@ -2,6 +2,7 @@
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import queue
|
||||
import struct
|
||||
@@ -97,7 +98,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','drone_equipment','system_status'):
|
||||
for field in ('rooms','crew','weapons','artillery','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'):
|
||||
@@ -133,7 +134,15 @@ def full_screen_capture(state):
|
||||
# Native shops, upgrades, crew and equipment are world panels, never HUDs.
|
||||
# An in-run window owns its pixels even if the native menu flag also sets
|
||||
# ui_mode to menu. Initial/hangar screens keep their full headset view.
|
||||
return not state.get('ready') or (state.get('ui_mode','menu') == 'menu' and not state.get('panel_open'))
|
||||
return bool(state.get('end_screen')) or not state.get('ready') or (state.get('ui_mode','menu') == 'menu' and not state.get('panel_open'))
|
||||
|
||||
|
||||
def apply_end_screen(state, is_open):
|
||||
state['end_screen']=bool(is_open)
|
||||
if is_open:
|
||||
state.update(ui_mode='menu',blocking_ui=True,panel_open=False,
|
||||
tactical=False,map_open=False,event_open=False,transition=False)
|
||||
return state
|
||||
|
||||
|
||||
def world_gameplay_available(state):
|
||||
@@ -316,8 +325,37 @@ def _translate_command(command, state):
|
||||
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}]
|
||||
phase=data.get('phase','click')
|
||||
if phase not in ('down','up','click','move'): raise ValueError('Invalid target phase')
|
||||
if 'point' in data:
|
||||
weapon=next((w for w in (state.get('player') or {}).get('weapons',[])
|
||||
if w.get('slot')==state.get('weapon_selected')),None)
|
||||
if target.get('kind','weapon')!='weapon' or not weapon or weapon.get('kind')!='beam':
|
||||
raise ValueError('Free endpoints are only valid for the selected native beam')
|
||||
point=data['point']
|
||||
if not isinstance(point,dict) or any(isinstance(point.get(k),bool) or
|
||||
not isinstance(point.get(k),(int,float)) or not math.isfinite(point[k]) for k in ('x','y')):
|
||||
raise ValueError('Beam endpoint must have finite coordinates')
|
||||
ship=state.get(side) or {}
|
||||
rects=[(r['x'],r['y'],r['w'],r['h']) for r in ship.get('rooms',[]) if all(k in r for k in ('x','y','w','h'))]
|
||||
image=ship.get('ship_image') or {}
|
||||
if all(k in image for k in ('x','y','w','h')): rects.append(tuple(image[k] for k in ('x','y','w','h')))
|
||||
if not rects or not (min(r[0] for r in rects)<=point['x']<=max(r[0]+r[2] for r in rects) and
|
||||
min(r[1] for r in rects)<=point['y']<=max(r[1]+r[3] for r in rects)):
|
||||
raise ValueError('Beam endpoint is outside the native ship bounds')
|
||||
origin=state[side+'_origin']
|
||||
x,y=round(origin['x']+point['x']),round(origin['y']+point['y'])
|
||||
if not (0<=x<1280 and 0<=y<720): raise ValueError('Beam endpoint is outside the native view')
|
||||
else:
|
||||
x,y = room_pixel(state, side, data['room_id'])
|
||||
if phase=='move': return [{'type':'move','x':x,'y':y}]
|
||||
if phase=='up' and 'point' in data:
|
||||
# Native beams commit on the second click, not MouseUp. Translate
|
||||
# the VR release to release then click; FTL chooses the final aim,
|
||||
# beam length and target vector, including its reversed ordering.
|
||||
return [{'type':'mouse','button':'left','phase':'up','x':x,'y':y},
|
||||
{'type':'mouse','button':'left','phase':'click','x':x,'y':y}]
|
||||
return [{'type':'mouse','button':'left','phase':phase,'x':x,'y':y}]
|
||||
if action == 'system_power':
|
||||
system_id=data.get('system_id')
|
||||
system_key=data.get('system_key')
|
||||
@@ -388,6 +426,7 @@ def main():
|
||||
stop = threading.Event()
|
||||
current = {}
|
||||
native_text_entry={'active':False}
|
||||
native_end_screen=False
|
||||
native_power_buttons={}
|
||||
power_buttons_received=0.0
|
||||
capture_size = [1280,720]
|
||||
@@ -457,8 +496,8 @@ def main():
|
||||
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 controller_screen or full_screen:
|
||||
publisher.publish('screen_frame',rgb.convert('RGBA') if full_screen else image,captured_at)
|
||||
if event_open:
|
||||
publisher.publish('event_frame',rgb.convert('RGBA'),captured_at)
|
||||
if panel_open:
|
||||
@@ -581,6 +620,7 @@ def main():
|
||||
latest=state; shots.extend(state.get('shots',[]))
|
||||
elif 'FTLVR_ERROR' in line: errors.append(line.strip())
|
||||
if latest:
|
||||
apply_end_screen(latest,native_end_screen)
|
||||
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)
|
||||
@@ -610,6 +650,11 @@ def main():
|
||||
atomic_json(local/'live_state.json',current)
|
||||
while not log_queue.empty():
|
||||
payload=log_queue.get()
|
||||
if payload.get('type')=='end_screen':
|
||||
native_end_screen=bool(payload['value']['open'])
|
||||
with state_lock:
|
||||
apply_end_screen(current,native_end_screen)
|
||||
continue
|
||||
if payload.get('type')=='text_entry':
|
||||
native_text_entry=dict(payload['value'])
|
||||
with state_lock:
|
||||
|
||||
+37
-1
@@ -7,12 +7,48 @@ from PIL import Image
|
||||
|
||||
import numpy as np
|
||||
from hud_alpha import clear_world_alpha, clear_supplemental_hud_alpha
|
||||
from run_bridge import (decode_state_line, translate_command, full_screen_capture,
|
||||
from run_bridge import (decode_state_line, translate_command, full_screen_capture,apply_end_screen,
|
||||
remove_enemy_hud, native_window_frame, remove_pause_overlay,
|
||||
FRAME_HEADER, raw_frame_bytes, FramePublisher)
|
||||
|
||||
|
||||
class BridgeTests(unittest.TestCase):
|
||||
def test_results_override_stale_native_modal_flags(self):
|
||||
state={'ready':True,'ui_mode':'game','panel_open':True,'tactical':True,
|
||||
'map_open':True,'event_open':True,'transition':True}
|
||||
apply_end_screen(state,True)
|
||||
self.assertTrue(full_screen_capture(state))
|
||||
self.assertTrue(state['blocking_ui'])
|
||||
for key in ('panel_open','tactical','map_open','event_open','transition'):
|
||||
self.assertFalse(state[key])
|
||||
fresh={'ready':True,'ui_mode':'game'}
|
||||
apply_end_screen(fresh,False)
|
||||
self.assertFalse(full_screen_capture(fresh))
|
||||
|
||||
def test_beam_free_endpoints_keep_subroom_precision_and_drag_movement(self):
|
||||
state={'ready':True,'ui_mode':'game','combat':True,'weapon_selected':0,
|
||||
'targeting':{'active':True,'kind':'weapon','ships':['enemy']},
|
||||
'player':{'weapons':[{'slot':0,'kind':'beam'}]},
|
||||
'enemy_origin':{'x':900,'y':105},
|
||||
'enemy':{'ship_image':{'x':-20,'y':-10,'w':250,'h':350},
|
||||
'rooms':[{'id':3,'x':0,'y':0,'w':35,'h':70,'center':{'x':17.5,'y':35}}]}}
|
||||
data={'room_id':-1,'ship':'enemy','point':{'x':72.25,'y':103.75}}
|
||||
for phase in ('down','up','move'):
|
||||
commands=translate_command({'action':'target_room','data':dict(data,phase=phase)},state)
|
||||
actual=commands[0]
|
||||
self.assertEqual((actual['x'],actual['y']),(972,209))
|
||||
self.assertEqual(actual['type'],'move' if phase=='move' else 'mouse')
|
||||
if phase=='up':
|
||||
self.assertEqual([c['phase'] for c in commands],['up','click'])
|
||||
self.assertTrue(all(c['x']==972 and c['y']==209 for c in commands))
|
||||
for point in ({'x':float('nan'),'y':20},{'x':True,'y':20},{'x':800,'y':20},'bad'):
|
||||
with self.assertRaises(ValueError):
|
||||
translate_command({'action':'target_room','data':dict(data,point=point)},state)
|
||||
state['targeting']['kind']='mind'
|
||||
with self.assertRaises(ValueError):translate_command({'action':'target_room','data':data},state)
|
||||
state['targeting']['kind']='weapon';state['player']['weapons'][0]['kind']='laser'
|
||||
with self.assertRaises(ValueError):translate_command({'action':'target_room','data':data},state)
|
||||
|
||||
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}]
|
||||
|
||||
@@ -134,6 +134,42 @@ func _run() -> void:
|
||||
effects.update_live_projectiles([])
|
||||
effects.simulation_paused = false
|
||||
effects._process(1.1)
|
||||
# Legacy radial-distance snapshots used to fall after a miss passed its
|
||||
# target. Both those snapshots and reordered modern ones must stay forward.
|
||||
effects.spawn_shot({"kind": "laser", "outcome": "pending", "projectile_id": "forward-miss",
|
||||
"target": {"x": 80, "y": 170}}, player, enemy)
|
||||
effects.update_live_projectiles([{"id": "forward-miss", "progress": 0.76}])
|
||||
effects._process(0.1)
|
||||
var forward_miss: Dictionary = effects.shots.back()
|
||||
var forward_node: MeshInstance3D = forward_miss["node"]
|
||||
var before_miss := forward_node.global_position
|
||||
effects.resolve_live({"projectile_id": "forward-miss", "outcome": "miss", "target": {"x": 80, "y": 170}}, enemy)
|
||||
effects._process(0.0)
|
||||
_check(forward_node.global_position.is_equal_approx(before_miss), "A late native miss decision must keep the projectile at its already presented point")
|
||||
effects.update_live_projectiles([{"id": "forward-miss", "progress": 0.94, "missed": true}])
|
||||
effects._process(0.1)
|
||||
var passed_position := forward_node.global_position
|
||||
var forward_direction: Vector3 = (Vector3(forward_miss["target_end"]) - Vector3(forward_miss["start"])).normalized()
|
||||
for backwards_progress in [0.81, 0.63, 0.5]:
|
||||
effects.update_live_projectiles([{"id": "forward-miss", "progress": backwards_progress, "missed": true}])
|
||||
effects._process(0.1)
|
||||
_check(forward_node.global_position.is_equal_approx(passed_position), "Legacy or reordered progress must never boomerang an already presented native miss")
|
||||
var duplicate_count: int = effects.shots.size()
|
||||
effects.spawn_shot({"kind": "laser", "outcome": "pending", "projectile_id": "forward-miss"}, player, enemy)
|
||||
_check(effects.shots.size() == duplicate_count and effects.shots.back()["node"] == forward_node, "Duplicate native fire must retain one original projectile without resetting its progress")
|
||||
effects.update_live_projectiles([{"id": "forward-miss", "progress": 1.3, "missed": true}])
|
||||
effects._process(0.1)
|
||||
_check((forward_node.global_position - passed_position).dot(forward_direction) > 0.2 and forward_miss["live_progress"] > 1.0, "A missed native projectile must continue forward beyond the target instead of stopping or returning")
|
||||
var before_pause := forward_node.global_position
|
||||
effects.simulation_paused = true
|
||||
effects.update_live_projectiles([{"id": "forward-miss", "progress": 1.4, "missed": true}])
|
||||
effects._process(0.5)
|
||||
_check(forward_node.global_position.is_equal_approx(before_pause), "Paused passing misses must remain frozen")
|
||||
var effects_before_absence: int = effects.impacts.size()
|
||||
effects.update_live_projectiles([])
|
||||
_check(effects.shots.is_empty() and effects.impacts.size() == effects_before_absence, "Native absence must remove a passing miss immediately without fabricated collision feedback")
|
||||
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}])
|
||||
@@ -254,6 +290,32 @@ func _run() -> void:
|
||||
_check(first_bullet.mesh == second_bullet.mesh and first_bullet.get_child(0).mesh == second_bullet.get_child(0).mesh, "Repeated drone shots must reuse both core and trail geometry")
|
||||
first_bullet.free()
|
||||
second_bullet.free()
|
||||
# The Flagship uses native artillery slots, not the ship's ordinary weapon
|
||||
# list. Sparse slots after a phase change must not wrap onto a regular mount.
|
||||
enemy._apply_artillery([{"slot": 1, "name": "ARTILLERY_LASER", "kind": "laser", "powered": true,
|
||||
"mount": {"x": 150, "y": 110}, "charge_fraction": 0.4},
|
||||
{"slot": 3, "name": "ARTILLERY_BEAM", "kind": "beam", "powered": true,
|
||||
"mount": {"x": 280, "y": 130}, "charge_fraction": 0.8}])
|
||||
_check(enemy.artillery_nodes.has(1) and enemy.artillery_nodes.has(3) and not enemy.artillery_nodes.has(0), "Sparse native artillery identities must retain their own models")
|
||||
var artillery_origin: Vector3 = enemy.artillery_origin(3)
|
||||
effects.spawn_shot({"kind": "beam", "outcome": "pending", "projectile_id": "flagship-beam",
|
||||
"artillery_slot": 3, "weapon_slot": 0, "target_room": 0}, enemy, player)
|
||||
effects.update_live_projectiles([{"id": "flagship-beam", "progress": 0.5, "beam_point": drone_target}])
|
||||
effects._process(0.1)
|
||||
var artillery_beam: Dictionary = effects.shots.back()
|
||||
_check(Vector3(artillery_beam["start"]).is_equal_approx(artillery_origin) and not artillery_origin.is_equal_approx(enemy.weapon_origin(0)), "Flagship fire must originate at its exact native artillery slot instead of ordinary weapon zero")
|
||||
var artillery_core: MeshInstance3D = artillery_beam["node"].get_node("BeamCore")
|
||||
_check(artillery_core.global_position.is_equal_approx((artillery_origin + player.to_global(player.pixel_point(drone_target))) * 0.5), "Flagship artillery sweep must connect its actual barrel to the native live target point")
|
||||
effects.simulation_paused = true
|
||||
enemy.position += Vector3(0.2, 0.1, -0.15)
|
||||
enemy.rotation.y += 0.25
|
||||
effects._process(0.1)
|
||||
_check(Vector3(artillery_beam["start"]).is_equal_approx(enemy.artillery_origin(3)), "Artillery emitter must follow paused encounter transforms without switching weapon slots")
|
||||
effects.update_live_projectiles([])
|
||||
effects.simulation_paused = false
|
||||
enemy._apply_artillery([{"slot": 3, "name": "ARTILLERY_BEAM", "kind": "beam", "powered": true,
|
||||
"mount": {"x": 280, "y": 130}, "charge_fraction": 0.9}])
|
||||
_check(enemy.artillery_nodes.size() == 1 and enemy.artillery_nodes.has(3), "An absent artillery slot must be removed when the Flagship changes phase")
|
||||
print("COMBAT_TESTS ", "PASS" if failures == 0 else "FAIL: %d" % failures)
|
||||
scene.queue_free()
|
||||
await process_frame
|
||||
|
||||
@@ -63,7 +63,7 @@ func run() -> void:
|
||||
var stopped_ship: AABB = Anchor.transformed_bounds(scene._player_hud_local_bounds(), scene.player_ship.global_transform)
|
||||
var stopped_panel: AABB = Anchor.panel_bounds(scene.hud_surface.global_transform, scene.hud_surface.surface_size)
|
||||
var gap: float = stopped_panel.position.y - stopped_ship.end.y
|
||||
check(gap >= .08 and gap < .10, "The blocked HUD must sit within ten centimetres of the ship while leaving a safe gap")
|
||||
check(gap >= .05 and gap < .07, "The blocked HUD must sit five to seven centimetres above the ship while retaining a safe gap")
|
||||
var clearance_lift: float = scene.hud_anchor.lift
|
||||
scene.camera.rotation.y = PI
|
||||
scene._position_hud(1.0 / 90)
|
||||
|
||||
@@ -108,6 +108,46 @@ 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")
|
||||
var frame_bindings := map.find_interaction_profile("/interaction_profiles/valve/frame_controller_valve").get_bindings()
|
||||
var menu_paths := []
|
||||
for binding in frame_bindings:
|
||||
if binding.action.resource_name == "menu_button": menu_paths.append(binding.binding_path)
|
||||
check("/user/hand/right/input/menu/click" in menu_paths and "/user/hand/left/input/view/click" in menu_paths,"Frame Pause and View must use the vendor-declared right Menu and left View paths")
|
||||
var left_tracker := XRControllerTracker.new()
|
||||
left_tracker.type = XRServer.TRACKER_CONTROLLER
|
||||
left_tracker.name = "left_hand"
|
||||
left_tracker.set_pose("aim",Transform3D(Basis.IDENTITY,Vector3(-0.3,1.2,0.5)),Vector3.ZERO,Vector3.ZERO,XRPose.XR_TRACKING_CONFIDENCE_HIGH)
|
||||
XRServer.add_tracker(left_tracker)
|
||||
scene._resolve_controller_pose(scene.left_hand)
|
||||
scene.hud_state = {"ready":true,"ui_mode":"game"}
|
||||
scene.nav_page = "navigation"
|
||||
scene._refresh_navigation()
|
||||
scene._face_navigation_to_headset()
|
||||
var before_view_count: int = scene.commands.size()
|
||||
left_tracker.set_input("menu_button",true)
|
||||
scene._update_pause_panel_buttons()
|
||||
check(scene.hand_screen_hidden and not scene.nav_panel.visible and scene.commands.size() == before_view_count,"Left View must hide the hand screen without issuing a native pause/menu action")
|
||||
for button in scene.nav_buttons:
|
||||
check(button.collision_layer == 0,"A hidden hand screen must not leave invisible navigation colliders")
|
||||
scene._face_navigation_to_headset()
|
||||
scene._refresh_navigation()
|
||||
scene._update_pause_panel_buttons()
|
||||
check(scene.hand_screen_hidden and not scene.nav_panel.visible,"Held View and subsequent state/pose refresh must not show the hand screen again")
|
||||
left_tracker.set_input("menu_button",false)
|
||||
scene._update_pause_panel_buttons()
|
||||
left_tracker.set_input("menu_button",true)
|
||||
scene._update_pause_panel_buttons()
|
||||
check(not scene.hand_screen_hidden and scene.nav_panel.visible,"Pressing left View again must restore the same hand screen")
|
||||
left_tracker.set_input("menu_button",false)
|
||||
scene._update_pause_panel_buttons()
|
||||
tracker.set_input("menu_button",true)
|
||||
scene._update_pause_panel_buttons()
|
||||
check(scene.commands.size() == before_view_count+1 and scene.commands.back().action == "key" and scene.commands.back().data.key == 32 and scene.nav_panel.visible,"Right Pause must send native Space without hiding the hand screen")
|
||||
scene._update_pause_panel_buttons()
|
||||
check(scene.commands.size() == before_view_count+1,"Holding right Pause must toggle the native pause once")
|
||||
tracker.set_input("menu_button",false)
|
||||
scene._update_pause_panel_buttons()
|
||||
scene.nav_panel.visible = false
|
||||
scene.hud_state = {"ready":true,"ui_mode":"game"}
|
||||
scene._sync_hud_layout()
|
||||
scene._position_hud(0.0, true)
|
||||
@@ -306,6 +346,34 @@ func run() -> void:
|
||||
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")
|
||||
scene.hud_state = {"ready":true,"ui_mode":"game","weapon_selected":0,"player":{"weapons":[{"kind":"beam"}]},"targeting":{"active":true,"kind":"weapon","ships":["enemy"]}}
|
||||
var saved_bounds: Dictionary = scene.enemy_ship.room_bounds
|
||||
var saved_image: Dictionary = scene.enemy_ship.layout_data.get("image_rect", {})
|
||||
var saved_table: Transform3D = scene.tabletop_root.transform
|
||||
scene.enemy_ship.room_bounds = {enemy_room:Rect2(-0.5,-0.2,0.4,0.4),999:Rect2(0.1,-0.2,0.4,0.4)}
|
||||
scene.enemy_ship.layout_data.image_rect = {}
|
||||
scene.tabletop_root.rotation.y += 0.47
|
||||
scene.tabletop_root.scale *= 1.6
|
||||
var first_local := Vector3(-0.499,0.0775,-0.199)
|
||||
var first_world: Vector3 = scene.enemy_ship.to_global(first_local)
|
||||
scene._select_from_ray(first_world+Vector3.UP,Vector3.DOWN)
|
||||
var first_command: Dictionary = scene.commands.back()
|
||||
var expected_native: Vector2 = Vector2(first_local.x,first_local.z) / scene.ShipModel.TILE + scene.enemy_ship.layout_center + Vector2(float(scene.enemy_ship.layout_data.get("x_offset",0)),float(scene.enemy_ship.layout_data.get("y_offset",0)))
|
||||
check(scene.beam_drag and first_command.data.phase == "down" and first_command.data.has("point"),"Beam press must supply the actual freely chosen native deck point")
|
||||
check(absf(float(first_command.data.point.x)-expected_native.x*35.0)<0.001 and absf(float(first_command.data.point.y)-expected_native.y*35.0)<0.001,"Beam endpoints must retain subpixel room-edge position under scaled/rotated table transforms")
|
||||
var gap_world: Vector3 = scene.enemy_ship.to_global(Vector3(0,0.0775,0.03))
|
||||
var gap_hit: Dictionary = scene._beam_deck_hit(gap_world+Vector3.UP,Vector3.DOWN,"enemy")
|
||||
check(not gap_hit.is_empty() and gap_hit.room_id == -1,"Free beam placement must allow the deck between rooms rather than snapping to a nearest room")
|
||||
scene.last_hover_time = -1.0
|
||||
scene._hover_ui(gap_world+Vector3.UP,Vector3.DOWN)
|
||||
check(scene.commands.back().data.phase == "move" and scene.commands.back().data.point == gap_hit.pixel,"Held beam aiming must forward native endpoint movement before release")
|
||||
scene._release_pointer(gap_world+Vector3.UP,Vector3.DOWN)
|
||||
check(not scene.beam_drag and scene.commands.back().data.phase == "up" and scene.commands.back().data.room_id == -1 and scene.commands.back().data.point == gap_hit.pixel,"Beam release must preserve an inter-room endpoint exactly")
|
||||
check(scene._beam_deck_hit(scene.enemy_ship.to_global(Vector3(9,0.0775,9))+Vector3.UP,Vector3.DOWN,"enemy").is_empty(),"Free beam projection must reject points outside the current ship's deck envelope")
|
||||
scene.enemy_ship.room_bounds = saved_bounds
|
||||
scene.enemy_ship.layout_data.image_rect = saved_image
|
||||
scene.tabletop_root.transform = saved_table
|
||||
scene.hud_state = {"ready":true,"ui_mode":"screen","tactical":true}
|
||||
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)
|
||||
@@ -441,6 +509,26 @@ func run() -> void:
|
||||
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)
|
||||
# A native end page may still report the previous ship and world-window flags.
|
||||
scene.hud_state = {"ready":true,"ui_mode":"game","end_screen":true,"panel_open":true,"map_open":true,"tactical":true,"paused":true}
|
||||
scene.world_surface.visible = true
|
||||
scene.event_surface.visible = true
|
||||
scene.wheel.visible = true
|
||||
scene.beam_drag = true
|
||||
scene.pending_page = "power"
|
||||
check(scene._apply_end_screen(scene.hud_state),"Native victory/death state must enter the full-screen presentation path")
|
||||
check(not scene.gameplay_hud and scene.hud_surface.source_rect == Rect2(0,0,1280,720) and scene.hud_surface.canvas.frame_file == "screen_frame.png","Final pages must use all native screen pixels regardless of stale in-run panel flags")
|
||||
check(not scene.world_surface.visible and not scene.event_surface.visible and not scene.map_surface.visible and not scene.nav_panel.visible and not scene.player_ship.visible and not scene.enemy_ship.visible and not scene.combat_effects.visible,"Final native pages must not be obscured by old combat or world UI")
|
||||
check(not scene.beam_drag and not scene.wheel.visible and scene.pending_page == "" and not scene.pause_label.visible,"Finished runs must cancel transient aiming and pause cues")
|
||||
var end_commands: int = scene.commands.size()
|
||||
scene._toggle_pause()
|
||||
check(scene.commands.size() == end_commands,"The Pause button must not send a gameplay key into a native final screen")
|
||||
scene.hud_state = {"ready":true,"ui_mode":"game"}
|
||||
check(not scene._apply_end_screen(scene.hud_state),"A new active run must leave the final-screen path")
|
||||
scene._sync_hud_layout()
|
||||
scene._face_navigation_to_headset()
|
||||
check(scene.gameplay_hud and scene.nav_panel.visible,"A new run must regain the compact HUD and visible tracked hand screen")
|
||||
XRServer.remove_tracker(left_tracker)
|
||||
XRServer.remove_tracker(tracker)
|
||||
scene.queue_free()
|
||||
await process_frame
|
||||
|
||||
@@ -36,9 +36,9 @@ class LaunchSetupTests(unittest.TestCase):
|
||||
names = ("GetShiftState", "GetCtrlState", "ForceAutofireFlag", "OnTextInput",
|
||||
"OnTextEvent", "TextInputOnRender", "TextInputStart", "CommandGuiRenderStatic",
|
||||
"CommandGuiRenderPause", "TabbedWindowOnRender", "ChoiceBoxOnRender",
|
||||
"MouseControlOnRender", "StarMapOnRender", "MenuScreenOnRender", "OptionsScreenOnRender")
|
||||
"MouseControlOnRender", "StarMapOnRender", "MenuScreenOnRender", "OptionsScreenOnRender", "CombatControlRenderTarget")
|
||||
self.hooks = self.lab / "hooks.json"
|
||||
self.hooks.write_text(json.dumps({"rvas": dict.fromkeys(names, 1),
|
||||
self.hooks.write_text(json.dumps({"rvas": dict.fromkeys(names, 1),"offsets":{"gui_game_over":0x1e88,"focus_window_open":4},
|
||||
"sha1": hashlib.sha1((self.lab / "FTLGame.exe").read_bytes()).hexdigest()}))
|
||||
self.godot = self.root / "godot.exe"
|
||||
self.godot.write_bytes(b"synthetic renderer placeholder")
|
||||
@@ -65,7 +65,7 @@ class LaunchSetupTests(unittest.TestCase):
|
||||
|
||||
def test_old_hooks_require_modal_hud_capture_update(self):
|
||||
value = json.loads(self.hooks.read_text())
|
||||
for name in ("MenuScreenOnRender", "OptionsScreenOnRender"):
|
||||
for name in ("MenuScreenOnRender", "OptionsScreenOnRender", "CombatControlRenderTarget"):
|
||||
with self.subTest(name=name):
|
||||
incomplete = dict(value, rvas={key: address for key, address in value["rvas"].items() if key != name})
|
||||
self.hooks.write_text(json.dumps(incomplete))
|
||||
|
||||
@@ -118,6 +118,8 @@ func _run() -> void:
|
||||
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.set_state({"hull": 8, "hull_max": 15})
|
||||
_check(is_equal_approx(hull_bar.hull_cell_rect(0).size.x, 45.0) and is_equal_approx(hull_bar.hull_cell_rect(14).position.x, 706.0), "Fifteen native hull units must fill the contour as fifteen individual pips")
|
||||
hull_bar.position = Vector3(1, 0.4, -1)
|
||||
var viewer := Vector3(0, 1.6, 0)
|
||||
hull_bar.face_viewer(viewer)
|
||||
@@ -264,6 +266,69 @@ func _run() -> void:
|
||||
_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")
|
||||
ship.position = Vector3.ZERO
|
||||
ship.rotation = Vector3.ZERO
|
||||
ship.scale = Vector3.ONE
|
||||
ship.set_shields(2, 0, false)
|
||||
_check(ship.shield_shell.visible, "Charged native shields must be visible")
|
||||
ship.set_shields(2, 0, true)
|
||||
_check(not ship.shield_shell.visible and ship.shield_charge == 2, "Native shield shutdown must hide the shell even when cached charge is unchanged")
|
||||
ship.set_shields(2, 0, false)
|
||||
_check(ship.shield_shell.visible, "Ending native shield shutdown must restore the shell without a charge transition")
|
||||
ship.set_shields(0, 0)
|
||||
_check(not ship.shield_shell.visible, "Depleted native shields must not retain a phantom shell")
|
||||
ship.set_shields(0, 5)
|
||||
_check(ship.shield_shell.visible, "Native super shields must remain visible without normal shield charge")
|
||||
var artillery := [
|
||||
{"slot": 0, "kind": "ion", "name": "BOSS_ION", "mount": {"x": 75, "y": 110}, "mount_rotate": false, "mount_mirror": true, "powered": true, "charge_fraction": 0.25},
|
||||
{"slot": 2, "kind": "laser", "name": "BOSS_LASER", "mount": {"x": 270, "y": 155}, "mount_rotate": true, "mount_mirror": false, "powered": true, "charge_fraction": 0.75},
|
||||
{"slot": 5, "kind": "missile", "name": "BOSS_MISSILE", "mount": {"x": 365, "y": 210}, "mount_rotate": false, "mount_mirror": false, "powered": false},
|
||||
{"slot": 7, "kind": "beam", "name": "BOSS_BEAM", "mount": {"x": 440, "y": 290}, "mount_rotate": true, "mount_mirror": true, "powered": true, "charge_fraction": 1.0}]
|
||||
ship.apply_live({"artillery": artillery, "weapons": [], "rooms": [room_data], "crew": [crew]})
|
||||
_check(ship.artillery_nodes.size() == 4 and not ship.weapon_nodes[0].visible, "Flagship artillery must create four models while ordinary native weapon equipment remains empty")
|
||||
for row in artillery:
|
||||
var gun: Node3D = ship.artillery_nodes[row.slot]
|
||||
var rect: Dictionary = ship.layout_data.image_rect
|
||||
var expected := Vector3((float(rect.x) + float(row.mount.x)) / 35.0 * ship.TILE - ship.layout_center.x * ship.TILE,
|
||||
0.14, (float(rect.y) + float(row.mount.y)) / 35.0 * ship.TILE - ship.layout_center.y * ship.TILE)
|
||||
_check(gun.position.is_equal_approx(expected) and ship.artillery_origin(row.slot).is_equal_approx(expected), "Artillery native mounts must use the image rectangle once, independently of sparse native slot IDs")
|
||||
_check(is_equal_approx(gun.rotation.y, 0.0 if row.mount_rotate else PI / 2.0) and is_equal_approx(gun.scale.z, -1.0 if row.mount_mirror else 1.0), "Artillery must retain native mount rotation and mirroring")
|
||||
_check(is_equal_approx(ship.artillery_nodes[2].charge, 0.75) and not ship.artillery_nodes[5].emitter.visible, "Native artillery cooldown and power must drive its visual state")
|
||||
var native_hull: MultiMeshInstance3D = ship.get_node("ExtrudedHull")
|
||||
var original_hull_material: StandardMaterial3D = native_hull.material_override
|
||||
var original_hazard_material: StandardMaterial3D = ship.room_hazards.flame_outer.material_override
|
||||
var cloak_state := {"artillery": artillery, "rooms": [room_data], "room_systems": {str(room_id): "shields"}, "crew": [crew], "cloaked": true, "cloak_progress": 1.0}
|
||||
ship.apply_live(cloak_state)
|
||||
_check(native_hull.material_override != original_hull_material and is_equal_approx(native_hull.material_override.albedo_color.a, 0.375), "Cloaking must fade hull materials in the Vulkan mobile renderer")
|
||||
_check(is_equal_approx(original_hull_material.albedo_color.a, 1.0) and original_hull_material.transparency == BaseMaterial3D.TRANSPARENCY_DISABLED, "Cloaking must leave original hull materials unmodified")
|
||||
_check(ship.room_hazards.flame_outer.material_override != original_hazard_material and ship.room_hazards.shared_visuals.FireOuter.material == original_hazard_material, "Cloaking must isolate immutable shared hazard materials from other ships")
|
||||
_check(ship.crew_nodes["model-test"].collision_layer == 1 and ship.crew_nodes["model-test"].get_node("Name").visible and room.get_node("SystemIcon").visible, "Cloaking must preserve controllable crew colliders and native targeting labels")
|
||||
room_data["oxygen"] = 0
|
||||
ship.apply_live(cloak_state)
|
||||
var cloak_floor: StandardMaterial3D = room.get_node("Floor").material_override
|
||||
_check(is_equal_approx(cloak_floor.albedo_color.a, 0.5) and cloak_floor.albedo_color.r > 0.9, "Native room tint updates must continue through cloaked material copies")
|
||||
cloak_state["cloaked"] = false
|
||||
cloak_state["cloak_progress"] = 0.5
|
||||
ship.apply_live(cloak_state)
|
||||
_check(is_equal_approx(native_hull.material_override.albedo_color.a, 0.6875), "Cloak fade-out must use native progress without inventing an animation clock")
|
||||
cloak_state["cloak_progress"] = 0.0
|
||||
ship.apply_live(cloak_state)
|
||||
_check(native_hull.material_override == original_hull_material and ship.room_hazards.flame_outer.material_override == original_hazard_material, "Uncloaking must restore the exact original material resources")
|
||||
cloak_state.erase("cloak_progress")
|
||||
cloak_state["cloaked"] = true
|
||||
ship.apply_live(cloak_state)
|
||||
ship._process(0.125)
|
||||
_check(is_equal_approx(ship.cloak_strength, 0.5) and is_equal_approx(native_hull.material_override.albedo_color.a, 0.6875), "Boolean native cloak state must ease the visible material fade when progress is unavailable")
|
||||
ship._process(0.125)
|
||||
_check(is_equal_approx(native_hull.material_override.albedo_color.a, 0.375), "Boolean cloak activation must reach the native fully cloaked hull alpha")
|
||||
cloak_state["cloaked"] = false
|
||||
ship.apply_live(cloak_state)
|
||||
ship._process(0.25)
|
||||
_check(native_hull.material_override == original_hull_material, "Boolean cloak deactivation must restore opaque hull geometry")
|
||||
ship.apply_live({"artillery": [artillery[0]]})
|
||||
_check(ship.artillery_nodes.size() == 1 and ship.artillery_nodes.has(0), "Destroyed or removed native artillery must disappear immediately")
|
||||
ship.apply_live({})
|
||||
_check(ship.artillery_nodes.is_empty(), "Absent native artillery must not leave phantom Flagship weapons")
|
||||
for asset in ["auto_assault", "fed_scout__fed_scout_pirate", "rebel_long", "energy_fighter_pirate"]:
|
||||
var opponent := ShipModel.new()
|
||||
root.add_child(opponent)
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
extends SceneTree
|
||||
|
||||
const ShipModel = preload("res://scripts/ship_model.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 rendered() -> void:
|
||||
for i in range(4):
|
||||
await process_frame
|
||||
await RenderingServer.frame_post_draw
|
||||
|
||||
|
||||
func _run() -> void:
|
||||
root.size = Vector2i(1280, 720)
|
||||
var state_path := ""
|
||||
var output := "res://local_game_data/ship-status"
|
||||
for arg in OS.get_cmdline_user_args():
|
||||
if arg.begins_with("--state="): state_path = arg.trim_prefix("--state=")
|
||||
if arg.begins_with("--output="): output = arg.trim_prefix("--output=")
|
||||
var recorded_native := not state_path.is_empty()
|
||||
var native: Dictionary = {}
|
||||
if recorded_native:
|
||||
var parsed: Variant = JSON.parse_string(FileAccess.get_file_as_string(state_path))
|
||||
if parsed is Dictionary:
|
||||
native = parsed.get("enemy", {})
|
||||
check(not native.is_empty(), "--state must reference a recorded snapshot containing an enemy ship")
|
||||
if native.is_empty():
|
||||
quit(1)
|
||||
return
|
||||
else:
|
||||
# Portable synthetic fixture, using only assets from the documented
|
||||
# owner-local extraction. No private campaign snapshot is required.
|
||||
native = {"layout": "rebel_long", "asset_key": "rebel_long", "shield": 2}
|
||||
var ship := ShipModel.new()
|
||||
root.add_child(ship)
|
||||
ship.build(str(native.get("asset_key", native.get("layout", "auto_assault"))), true)
|
||||
check(ship.hull_voxel_count > 0, "Extract the requested owned ship assets before running the render test")
|
||||
if ship.hull_voxel_count == 0:
|
||||
quit(1)
|
||||
return
|
||||
ship.inspect_rooms = true
|
||||
ship.simulation_paused = true
|
||||
# Hold geometry steady so the restored image has a direct pixel oracle.
|
||||
ship.set_process(false)
|
||||
var state := native.duplicate(true)
|
||||
state["cloaked"] = false
|
||||
state["cloak_progress"] = 0.0
|
||||
ship.apply_live(state)
|
||||
if recorded_native:
|
||||
var shield_shape: Dictionary = native.get("shield_shape", {})
|
||||
check(not shield_shape.is_empty(), "A native snapshot must contain its authoritative shield_shape")
|
||||
var expected_center := ship.pixel_point(shield_shape.get("center", {}), ship.shield_center.y)
|
||||
check(ship.shield_center.is_equal_approx(expected_center), "The shield must preserve the actual native ellipse center without an extra enemy offset")
|
||||
var outside_corners := 0
|
||||
var maximum_radius := 0.0
|
||||
for point in ship.shield_fit_points:
|
||||
var radius: float = ((point - ship.shield_center) / ship.shield_radii).length()
|
||||
maximum_radius = maxf(maximum_radius, radius)
|
||||
if radius > 1.001: outside_corners += 1
|
||||
check(outside_corners == 0, "Every extruded hull and room-wall corner must fit inside the shield volume")
|
||||
check(ship.artillery_nodes.size() == native.get("artillery", []).size(), "Every recorded native artillery weapon must have a rendered model")
|
||||
for row in native.get("artillery", []):
|
||||
var gun: Node3D = ship.artillery_nodes[int(row.slot)]
|
||||
var rect: Dictionary = ship.layout_data.image_rect
|
||||
var expected_mount := Vector3((float(rect.x) + float(row.mount.x)) / 35.0 * ship.TILE - ship.layout_center.x * ship.TILE,
|
||||
0.14, (float(rect.y) + float(row.mount.y)) / 35.0 * ship.TILE - ship.layout_center.y * ship.TILE)
|
||||
check(gun.position.is_equal_approx(expected_mount), "Recorded native artillery mounts must remain aligned with the actual hull image")
|
||||
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.6
|
||||
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)
|
||||
await rendered()
|
||||
var normal := root.get_texture().get_image()
|
||||
normal.save_png(output + "-normal.png")
|
||||
state["cloaked"] = true
|
||||
state["cloak_progress"] = 1.0
|
||||
ship.apply_live(state)
|
||||
await rendered()
|
||||
var faded := root.get_texture().get_image()
|
||||
faded.save_png(output + "-cloaked.png")
|
||||
var changed_pixels := 0
|
||||
for y in range(normal.get_height()):
|
||||
for x in range(normal.get_width()):
|
||||
var before := normal.get_pixel(x, y)
|
||||
var after := faded.get_pixel(x, y)
|
||||
if Vector3(before.r - after.r, before.g - after.g, before.b - after.b).length() > 0.06:
|
||||
changed_pixels += 1
|
||||
check(changed_pixels > 15000, "Cloaking must visibly change the mobile Vulkan ship image, including batched hull geometry")
|
||||
state["cloaked"] = false
|
||||
state["cloak_progress"] = 0.0
|
||||
ship.apply_live(state)
|
||||
await rendered()
|
||||
var restored := root.get_texture().get_image()
|
||||
restored.save_png(output + "-restored.png")
|
||||
var normal_bytes := normal.get_data()
|
||||
var restored_bytes := restored.get_data()
|
||||
var restore_error := 0
|
||||
var restore_total_error := 0
|
||||
for i in range(normal_bytes.size()):
|
||||
var error := absi(int(normal_bytes[i]) - int(restored_bytes[i]))
|
||||
restore_error = maxi(restore_error, error)
|
||||
restore_total_error += error
|
||||
var restore_mean_error := float(restore_total_error) / maxf(normal_bytes.size(), 1)
|
||||
# Vulkan blending can round isolated edge pixels by two bytes when its
|
||||
# transparent pass is removed. Larger changes reveal a lingering fade.
|
||||
check(restore_error <= 2 and restore_mean_error < 0.001, "Uncloaking must return the rendered ship to its original pixels within isolated GPU byte rounding")
|
||||
var bar := HullBar.new()
|
||||
root.add_child(bar)
|
||||
bar.build()
|
||||
bar.set_state({"hull": 8, "hull_max": 15})
|
||||
await rendered()
|
||||
var hull_image := bar.viewport.get_texture().get_image()
|
||||
hull_image.save_png(output + "-hull-8-of-15.png")
|
||||
var green_runs := 0
|
||||
var previous_green := false
|
||||
for x in range(34, 754):
|
||||
var color := hull_image.get_pixel(x, 74)
|
||||
var green := color.g > 0.9 and color.r < 0.6 and color.a > 0.8
|
||||
if green and not previous_green:
|
||||
green_runs += 1
|
||||
previous_green = green
|
||||
check(green_runs == 8, "Eight of fifteen native hull points must render exactly eight lit pips, without the player mask's thirty separators")
|
||||
for i in range(15):
|
||||
var color := hull_image.get_pixel(roundi(bar.hull_cell_rect(i).get_center().x), 74)
|
||||
check((color.g > 0.9) == (i < 8), "Each rendered hull cell must match its native health unit")
|
||||
bar.set_state({"hull": 8, "hull_max": 22})
|
||||
await rendered()
|
||||
hull_image = bar.viewport.get_texture().get_image()
|
||||
hull_image.save_png(output + "-hull-8-of-22.png")
|
||||
green_runs = 0
|
||||
previous_green = false
|
||||
for x in range(34, 754):
|
||||
var color := hull_image.get_pixel(x, 74)
|
||||
var green := color.g > 0.9 and color.r < 0.6 and color.a > 0.8
|
||||
if green and not previous_green: green_runs += 1
|
||||
previous_green = green
|
||||
check(green_runs == 8, "A different native maximum must still display exactly eight remaining hull pips")
|
||||
bar.queue_free()
|
||||
var native_artillery_count := ship.artillery_nodes.size()
|
||||
var shield_padding := ship.shield_fit_padding
|
||||
ship.queue_free()
|
||||
await process_frame
|
||||
print("SHIP_STATUS_RENDER_TESTS ", "PASS" if failures == 0 else "FAIL %d" % failures,
|
||||
" fixture=", "recorded_native" if recorded_native else "synthetic_owned_asset",
|
||||
" renderer=", RenderingServer.get_current_rendering_method(), " cloak_changed_pixels=", changed_pixels,
|
||||
" restored_max_byte_error=", restore_error, " restored_mean_byte_error=", restore_mean_error,
|
||||
" hull_units=8/15+8/22 layout=", native.get("layout", ""),
|
||||
" artillery=", native_artillery_count, " outside_shield_corners=", outside_corners, " maximum_corner_radius=", maximum_radius,
|
||||
" shield_padding=", shield_padding, " output=", output)
|
||||
quit(0 if failures == 0 else 1)
|
||||
Reference in new issue
Block a user