Fix Flagship visuals, beam aiming, results and Frame controls

This commit is contained in:
ElHombreAlex committed 2026-10-05 10:52:16 +02:00
1 parent e857f4eee9
commit cf9cfca866
27 files changed
+1084 -96

No files matched your search

+15
View File
@@ -1,5 +1,20 @@
# Changelog
## Combat, results and controller corrections — source update, 2026-10-05
- Make every enemy hull pip represent one native hit point, including enemies whose maximum hull differs from the player's 30-point scale.
- Render artillery from a separate native factory list, including the Flagship's individual artillery mounts and shot origins.
- Propagate native shield shutdown state and include it in the cached shield state; drive cloaking from the actual native ship flag. Native shield ellipse geometry is unchanged and checked against the game.
- Keep missed projectiles moving forward through their native pass-by path without a reversal or invented impact.
- Place both beam endpoints freely on the enemy deck plane, including gaps between rooms; preserve native aim, length and damage rules.
- Restore the complete native 2D screen for defeat/victory results and clear tabletop ships, combat effects, target marks and controller panels during results.
- Reduce the HUD's collision clearance from 8 cm to 5 cm. Map Steam Frame's physical right Pause/Menu button to pause/resume and left View to controller-panel hide/show.
- Isolate native targeting graphics from the supplemental HUD pass and detect the native GameOver window through bounded read-only state. Existing installations must rerun [hook resolution](docs/INSTALLATION.md#4-resolve-the-local-executable-hooks) with the matching `CombatControl.zhl`, `GameOver.zhl` and `FocusWindow.zhl` files.
Verification: 46 Python tests and twelve Godot suites pass, including four graphical Vulkan suites. Copied-profile native checks verify 4/3/2 Flagship artillery factories, first-phase cloaking, unchanged shield ellipse geometry and exact off-center/gap beam placement into native queued-shot state. Native Victory/credits and defeat retain the full opaque screen with inspected Vulkan tabletop cleanup; defeat returns cleanly to the actual main menu. Recorded geometry and rendered fixtures verify shield containment, one-HP hull pips and forward missed-shot cleanup without invented impacts. Live native beam/miss outcomes remain unverified because the tested factories queued shots without emitting live projectiles.
Local delivery passes: restored production state, development and both standalone setup/readiness and desktop Vulkan launch/exit, clean logs, preserved campaign/executable hashes and regenerated hooks. The 127-file source-only package passes publication checks with zero failures/warnings. Local source folders are updated; the verified source snapshot is recorded in Git history. No physical Steam Frame or stereo-performance result is recorded for October 5.
## Targeting, jump and drone refinements — source update, 2026-10-03
- Display the player's native placed weapon targets as persistent numbered room locks, red for a single volley and yellow for autofire. Preserve real beam endpoints/direction and native flak radius; keep marks attached during pause and encounter transforms, and clear them when native targets or the encounter end.
+15 -7
View File
@@ -11,11 +11,11 @@ An experimental OpenXR mod for **FTL: Faster Than Light**. Walk around a miniatu
## What it does
- Floating player and enemy ships with opposing bows, room interiors, visible shields and movable encounter placement.
- Compact original HUD following the headset, with 8 cm clearance above the ship when needed; upper status and crew roster, controller navigation, Tactical, shortcuts, system power and illustrated Help.
- Crew selection and drag-to-room orders, door controls, weapon/beam targeting, persistent numbered native weapon locks with autofire colors/beam direction/flak spread, and native system targeting.
- Contextual Jump/Ship/Store actions, a cockpit-anchored sector map, floating event dialogs and complete native shop/ship/Options panels displayed only above the ship during a run.
- Compact original HUD following the headset, with 5 cm clearance above the ship when needed; upper status and crew roster, controller navigation, Tactical, shortcuts, system power and illustrated Help.
- Crew selection and drag-to-room orders, door controls, free beam endpoint placement across rooms and gaps, persistent numbered native weapon locks with autofire colors/beam direction/flak spread, and native system targeting.
- Contextual Jump/Ship/Store actions, a cockpit-anchored sector map, floating event dialogs and complete native shop/ship/Options panels displayed only above the ship during a run; complete 2D native defeat/victory results.
- An eight-slot action wheel that opens on weapons/drones each time, with localized equipped names, family/ammo hints, Shift/Ctrl shortcuts and a QWERTY VR keyboard for renaming crew and ships.
- Original procedural voxel crew, weapons and distinct drone families/Mk II variants, native drone muzzle shots, weapon charge indicators, room-condition feedback, cross-shaped breaches and native miss cues.
- Original procedural voxel crew, weapons and distinct drone families/Mk II variants, separate native artillery mounts, native shield/cloak state, weapon charge indicators, room-condition feedback, cross-shaped breaches and forward-moving native miss cues. Enemy hull pips each represent one hit point.
- Surrounding stars that stretch along the ship's bow during an actual native jump, plus native-state-driven asteroid, sun, nebula/storm and pulsar environments.
- A separate game copy and VR save prefix, plus a backup before each normal launch.
@@ -66,15 +66,23 @@ The [controls guide](docs/CONTROLS.md) explains crew movement, room targeting, t
## Current state
The latest refinement displays the player's real numbered weapon target marks on rooms, using red for a single volley and yellow for native autofire. Beam locks retain both native endpoints and direction; flak shows its native spread radius. Locks stay attached while the encounter is moved or paused and clear when native targets or the encounter end. Jump stars follow the player's ship bow and return to normal on arrival, including when the arrival dialog opens. Each wheel opening defaults to weapons/drones. Procedural drone models now distinguish Combat, Beam and Defense Mk II hardware; native shots leave their visible muzzles. A scoped transparency mask preserves the native intruder warning's lettering and shadow/fringe while removing its backdrop.
The October 5 correction makes each enemy hull pip equal one actual hit point, gives native artillery its own mounts and shot origins, keeps misses moving forward, and allows beam endpoints anywhere on the enemy deck plane, including gaps between rooms. Shields follow native charge/shutdown state and cloaking follows the native ship flag. Native defeat/victory results use the complete 2D screen with tabletop combat presentation cleared. Steam Frame's right Pause/Menu button pauses; left View hides or restores the controller panel. The HUD collision gap is 5 cm.
Current checks pass **46 Python tests and twelve Godot suites**, including four graphical Vulkan suites. Native copied-profile checks verify the Flagship's 4/3/2 artillery factories, actual first-phase cloaking and unchanged native shield ellipse geometry through all three phases. Native beam placement accepts exact off-center and gap endpoints through the production release/confirmation sequence and reaches the native queued-shot state. Controlled native Victory/credits and defeat checks retain the full opaque 2D screen, with inspected Vulkan tabletop cleanup; returning from defeat restores the actual main menu. These checks verify result integration rather than campaign completion. Five recorded ship layouts retain hull/keel containment inside the shield volume. Rendered hull fixtures show exactly eight lit pips for both 8/15 and 8/22 hull, and a five-frame missed-shot fixture progresses forward and clears without an invented impact.
Live native beam/miss outcomes remain unverified: tested factories queued shots without emitting live projectiles. Production state is restored. Development and both standalone setup/readiness and desktop Vulkan launch/exit checks pass with clean logs, unchanged campaign hashes and preserved executable fingerprints. The 127-file source-only package passes publication checks with zero failures/warnings. Local source folders are updated; the verified source snapshot is recorded in Git history. Physical headset testing has not been performed for this revision.
The preceding refinement displays the player's real numbered weapon target marks, red for a single volley and yellow for native autofire. Beam locks retain both native endpoints and direction; flak shows its native spread radius. Locks stay attached while the encounter is moved or paused and clear when native targets or the encounter end. Jump stars follow the player's ship bow and return to normal on arrival. Each wheel opening defaults to weapons/drones. Drone models distinguish Combat, Beam and Defense Mk II hardware; native shots leave their visible muzzles. A scoped transparency mask preserves the native intruder warning's lettering and shadow/fringe while removing its backdrop.
**Existing installations must rerun [local asset extraction](docs/INSTALLATION.md#5-extract-local-presentation-assets) with the updated `tools/extract_ftl.py` against the original owned archive.** It now imports eight placed-target reticle PNGs: slots 1–4 in normal and yellow/autofire variants. These game assets remain local and are not distributed with the source.
The maintainer has played substantial portions of runs on Steam Frame and confirmed that earlier revisions are playable. The compact gameplay HUD follows the headset first and stops above the ship only when it would intersect the hull or shield. Its collision clearance is now 8 cm, bringing it 6 cm closer. In-run Ship, pause-menu and Options windows appear only above the ship, without a duplicate on the headset HUD. Equipped weapon entries remain available on the wheel when no drones are installed. Room floors retain role icons and condition colors; health/status bars and counters are removed. System Power, storm-aware free power, native miss feedback and cross-shaped breaches remain available.
The maintainer has played substantial portions of runs on Steam Frame and confirmed that earlier revisions are playable. The compact gameplay HUD follows the headset first and stops 5 cm above the ship only when it would intersect the hull or shield. In-run Ship, pause-menu and Options windows appear only above the ship, without a duplicate on the headset HUD. Equipped weapon entries remain available on the wheel when no drones are installed. Room floors retain role icons and condition colors; health/status bars and counters are removed. System Power, storm-aware free power, native miss feedback and cross-shaped breaches remain available.
Core game input, capture, native state and graceful launch/exit have been verified in an isolated game. **Complete campaign coverage and measured stereo headset performance remain open.** Hacking target acceptance is verified; drone attachment/effect completion still needs encounter testing. The renderer presents hazards and some effects approximately while the original game remains authoritative.
The current source passed **44 Python tests and eleven Godot suites**, with inspected Vulkan target/jump/drone captures. A disposable copied run verified actual normal/cleared/autofire targets and a real connected-beacon jump through its arrival dialog. Twelve native intruder-warning blink captures verified transparency without losing glyphs or ordinary HUD ink. Both prepared launch folders passed setup and desktop Vulkan launch/exit checks; all four original campaign hashes and both game executable fingerprints are preserved. These checks do not establish physical Steam Frame comfort or stereo performance.
The preceding targeting/jump/drone source passed **44 Python tests and eleven Godot suites**, with inspected Vulkan captures. A disposable copied run verified actual normal/cleared/autofire targets and a real connected-beacon jump through its arrival dialog. Twelve native intruder-warning blink captures verified transparency without losing glyphs or ordinary HUD ink. Both prepared launch folders passed setup and desktop Vulkan launch/exit checks; all four original campaign hashes and both game executable fingerprints were preserved. These results belong to that revision and do not establish physical Steam Frame comfort or stereo performance.
**Existing installations must refresh their local hooks** using [Installation step 4](docs/INSTALLATION.md#4-resolve-the-local-executable-hooks). The current resolver also needs the matching `CombatControl.zhl`, `GameOver.zhl` and `FocusWindow.zhl` signatures for isolated HUD target rendering and read-only native result-window detection.
The preceding HUD clearance/menu revision passed 40 Python tests and focused Vulkan HUD/input checks. Its native before/after captures verified that Ship, pause-menu and Options windows remain complete above the ship while their duplicate HUD pixels are absent. Both local folders passed their VR-wrapper desktop smoke launches with clean client logs and no bridge errors; setup and VR readiness checks passed separately. Campaign files remained unchanged. **Existing installations must also refresh their local hooks** using [Installation step 4](docs/INSTALLATION.md#4-resolve-the-local-executable-hooks) if they have not applied that revision.
+11 -1
View File
@@ -46,6 +46,15 @@ methods.OnMouseMove = new NativeFunction(base.add(config.rvas.OnMouseMove), 'voi
let lastMouse = [0, 0];
let leftHeld = false, rightHeld = false;
let app = null;
let lastEndScreenReport=0;
function reportEndScreen() {
if (app===null || Date.now()-lastEndScreenReport<100) return;
lastEndScreenReport=Date.now();
const commandGui=app.add(config.offsets.app_gui).readPointer();
const open=!commandGui.isNull() && commandGui.add(config.offsets.gui_game_over)
.add(config.offsets.focus_window_open).readU8()===1;
send({type:'end_screen',value:{open}});
}
const capturePerformance={started_ms:Date.now(),native_loops:0,native_swaps:0,frames:0,hud_frames:0,
capture_ms:0,hud_ms:0,read_ms:0,loop_ms:0,bytes_sent:0};
let activeText = null, textGeneration = 0, lastTextReport = 0, textWasActive = false;
@@ -147,6 +156,7 @@ function attachInput() {
this.perfStart=Date.now();capturePerformance.native_loops++;
app = this.context.ecx;
reportText();
reportEndScreen();
// Execute FTL input handlers on its main thread without taking over desktop input.
const pending = queue.length && queue[0].ready_at <= Date.now() ? queue.splice(0, 1) : [];
if (pending.length) focus(app);
@@ -343,7 +353,7 @@ function attachHudPass() {
let code='extern unsigned int hud_pass;\n';
// Esc and Options are separate render methods from the ship/store tabs.
// They belong only to the normal screen capture used by the world panel.
const names=['TabbedWindowOnRender','ChoiceBoxOnRender','MouseControlOnRender','CommandGuiRenderPause','StarMapOnRender','MenuScreenOnRender','OptionsScreenOnRender'];
const names=['TabbedWindowOnRender','ChoiceBoxOnRender','MouseControlOnRender','CommandGuiRenderPause','StarMapOnRender','MenuScreenOnRender','OptionsScreenOnRender','CombatControlRenderTarget'];
for (let i=0;i<names.length;i++) {
if (!config.rvas[names[i]]) throw new Error('Missing native HUD method '+names[i]);
symbols['original_'+i]=Memory.alloc(Process.pointerSize);
+37 -9
View File
@@ -107,7 +107,8 @@ local function ship_snapshot(ship, gui)
local graph = Hyperspace.ShipGraph.GetShipInfo(ship.iShipId)
local result = {layout=ship.myBlueprint.layoutFile, image=ship.myBlueprint.imgFile,
hull=ship.ship.hullIntegrity.first, hull_max=ship.ship.hullIntegrity.second,
destroyed=ship.bDestroyed, rooms={}, crew={}, weapons={}, doors={}, drones={},drone_equipment={},
destroyed=ship.bDestroyed, cloaked=ship.ship.bCloaked,
rooms={}, crew={}, weapons={}, artillery={}, doors={}, drones={},drone_equipment={},
systems={}, system_power={}, system_status={}, room_systems={}}
-- Native sensor_4 permits enemy power bars; sensor_2 only reveals rooms.
-- Condition colours remain public, just as the native target icons do.
@@ -154,6 +155,7 @@ local function ship_snapshot(ship, gui)
local shield = ship:GetShieldPower()
result.shield = shield.first
result.super_shield = shield.super.first
result.shield_shutdown=ship.shieldSystem and ship.shieldSystem.shields_shutdown or false
local ellipse=ship.ship:GetBaseEllipse()
result.shield_shape={center=point(ellipse.center),a=ellipse.a,b=ellipse.b}
local image=ship.ship.shipImage
@@ -244,17 +246,18 @@ local function ship_snapshot(ship, gui)
append_space_drone(result.drones,ship.spaceDrones[i],i)
end
local weapons = ship:GetWeaponList()
for i=0, weapons:size()-1 do
local weapon = weapons[i]
local function append_weapon(list, weapon, i)
if not weapon then return end
local kind=weapon.blueprint.typeName:lower()
if kind=='missiles' then kind='missile' end
if kind=='burst' then kind='flak' end
if weapon.blueprint.damage.iIonDamage>0 then kind='ion' end
result.weapons[#result.weapons+1] = {slot=i, name=weapon.blueprint.name,
list[#list+1] = {slot=i, name=weapon.blueprint.name,
title=blueprint_title(weapon.blueprint,false),short_title=blueprint_title(weapon.blueprint,true),
ammo_cost=weapon.blueprint.missiles,
kind=kind, powered=weapon.powered, autofire=weapon.autoFiring, charge=weapon.cooldown.first,
cooldown=weapon.cooldown.second, mount=point(weapon.mount.position),
mount_rotate=weapon.mount.rotate,mount_mirror=weapon.mount.mirror,
charge_fraction=weapon.cooldown.second>0 and math.min(1,math.max(0,weapon.cooldown.first/weapon.cooldown.second)) or 1,
charge_level=weapon.chargeLevel,charge_max=weapon.blueprint.chargeLevels,
required_power=weapon.requiredPower,ready=weapon.powered and weapon.cooldown.first>=weapon.cooldown.second,
@@ -263,7 +266,7 @@ local function ship_snapshot(ship, gui)
muzzle=point(weapon.weaponVisual.fireLocation), firing=weapon.weaponVisual.bFiring}
-- Display the player's real placed aiming marks, never an enemy's intent.
if ship.iShipId==0 then
local row=result.weapons[#result.weapons]
local row=list[#list]
row.targets={}
for n=0,weapon.targets:size()-1 do row.targets[#row.targets+1]=point(weapon.targets[n]) end
row.target_ship=weapon.targetId
@@ -271,6 +274,12 @@ local function ship_snapshot(ship, gui)
row.beam_length=weapon.blueprint.length
end
end
for i=0, weapons:size()-1 do append_weapon(result.weapons,weapons[i],i) end
-- Flagship turrets and artillery systems are separate native factories.
-- Keep them out of the ordinary weapon slots used by controller shortcuts.
for i=0,ship.artillerySystems:size()-1 do
append_weapon(result.artillery,ship.artillerySystems[i].projectileFactory,i)
end
return result
end
@@ -388,11 +397,22 @@ local function snapshot()
alive[p.selfId]=true
local progress=math.min(0.48,distance(p.position,record.start)/600)
if p.currentSpace==p.targetId then
local remaining=distance(p.position,p.target)
record.entry_distance=record.entry_distance or math.max(1,remaining)
progress=0.5+0.5*(1-math.min(1,remaining/record.entry_distance))
if not record.entry then
record.entry=point(p.position)
record.dx=p.target.x-p.position.x
record.dy=p.target.y-p.position.y
record.entry_length2=math.max(1,record.dx*record.dx+record.dy*record.dy)
end
-- Signed travel along the target-space segment continues past
-- the target after a miss. Radial distance would turn it back.
local travel=((p.position.x-record.entry.x)*record.dx+
(p.position.y-record.entry.y)*record.dy)/record.entry_length2
progress=0.5+0.5*math.max(0,travel)
if not p.missed then progress=math.min(1,progress) end
end
local live={id=tostring(p.selfId), progress=progress, missed=p.missed}
record.progress=math.max(record.progress or 0,progress)
local live={id=tostring(p.selfId), progress=record.progress, missed=p.missed,
current_space=p.currentSpace,target_space=p.targetId,position=point(p.position)}
if record.beam and p.currentSpace==p.targetId then
live.beam_point=point(record.beam.sub_end)
end
@@ -436,14 +456,22 @@ script.on_internal_event(Defines.InternalEvents.PROJECTILE_FIRE, function(projec
if projectile.damage.iIonDamage > 0 then kind='ion' end
local graph = Hyperspace.ShipGraph.GetShipInfo(projectile.targetId)
local slot=0
local artillery_slot=nil
local sender=Hyperspace.ships(projectile.ownerId)
if sender then
local list=sender:GetWeaponList()
for i=0,list:size()-1 do
if list[i].mount.position.x==weapon.mount.position.x and list[i].mount.position.y==weapon.mount.position.y then slot=i;break end
end
for i=0,sender.artillerySystems:size()-1 do
local factory=sender.artillerySystems[i].projectileFactory
if factory and factory.mount.position.x==weapon.mount.position.x and factory.mount.position.y==weapon.mount.position.y then
artillery_slot=i;break
end
end
end
local event = {id='fire-'..tostring(id), projectile_id=tostring(projectile.selfId), weapon_slot=slot,
artillery_slot=artillery_slot,
source=projectile.ownerId==0 and 'player' or 'enemy', kind=kind,
target_space=projectile.targetId,
start=point(projectile.position), target=point(projectile.target),
+8 -2
View File
@@ -45,6 +45,8 @@ Godot uses this state to place and animate visual actors. Native positions, curr
Projectiles originate from native weapon/drone locations and follow native shot information. Rendering does not determine hits or damage. Pause freezes presentation clocks while keeping actual native paused projectiles visible and removing stale shots. Environmental animation conveys the current hazard; exact hazard timing remains a polish/verification area.
Artillery uses a separate native `artillerySystems`/projectile-factory list so Flagship guns retain individual mount and shot identities even when regular weapon slots are empty. Enemy hull cells are generated from `hull_max`, with one cell per native hit point. Shields use current native charge, super charge and `shields_shutdown`; propagate shutdown through the main presentation update and include it in the shield-state cache. `Ship::GetBaseEllipse` supplies their unchanged center/radii. Cloaking reads `ship.ship.bCloaked` rather than inferring it from a cooldown or hiding the ship's gameplay state.
Placed weapon locks use the player's current native `targets`, `target_ship`, `autofire`, beam length and flak radius, independently of the currently armed cursor. The renderer does not infer locks from projectile history or expose enemy weapon intent. Native beam targets supply both endpoints and their sweep direction; incomplete endpoints do not create a fabricated beam lock. Room marks inherit the receiver's transform through pause, movement, tilt and scale. Clearing native targets or leaving the encounter removes their marks.
Space drones use native IDs and current render space to locate the procedural model's visible muzzle. Bullet launch points stay fixed in that space as the drone moves; a continuing native beam remains attached to its emitter. Ambiguous negative drone IDs use the native origin point at drone height. Native shot kind, live progress, presence and actual collision outcome control presentation. Model bodies, exhaust, emitters and tools share immutable geometry; emission state uses cached materials without changing another drone's glow. Combat, Beam and Defense Mk II variants have distinct hardware.
@@ -53,11 +55,15 @@ Jump stretch starts from the native ship `jumping` flag and follows the transfor
The native missed flag and a post-update event feed evasion feedback so brief missed shots can produce a single MISS cue even between snapshot deliveries. Room condition colors respect native information permissions; role icons and hazards remain, without floor health/status bars or counters. Reactor snapshots distinguish raw availability from usable availability after native capacity/environmental limits; battery availability is separate. Equipped localized weapon/drone metadata is distinct from deployed drone actors, keeping wheel labels tied to actual shortcut slots. Optional collections are normalized by the bridge and parsed defensively by the wheel: Lua may encode an empty table as `{}` rather than `[]`.
Miss presentation preserves forward progress when switching to a pass-by path; a later native update cannot send the visible shot backward toward its source. It creates no collision or damage event. Beam pointing intersects the enemy deck plane independently of room-floor snapping and maps both freely placed endpoints back to native pixels. Release sends native button-up and the native confirmation click required to complete FTL's aim. The native factory retains its reversed endpoint ordering. Gaps are valid aim positions; the game controls accepted beam geometry and outcomes.
## Original HUD and floating windows
The game renders its original HUD into a separate native framebuffer. This retains native fonts, localization and values even when the normal backbuffer displays Tactical, the map or a window. The bridge removes unwanted world/enemy HUD pixels and preserves complete native window content for floating panels. Supplemental capture suppresses native window draw methods, including the separate `MenuScreen::OnRender` and `OptionsScreen::OnRender` methods, so in-run panels are not duplicated on the headset HUD. Normal capture remains unchanged. Gameplay crops the upper native status and crew region to a fixed-size headset-relative panel, omitting the lower strip; Power/Shortcuts/the wheel supply those actions. Main menus and desktop Tactical/map views retain full framing.
The game renders its original HUD into a separate native framebuffer. This retains native fonts, localization and values even when the normal backbuffer displays Tactical, the map or a window. The bridge removes unwanted world/enemy HUD pixels and preserves complete native window content for floating panels. Supplemental capture suppresses native window draw methods, including the separate `MenuScreen::OnRender` and `OptionsScreen::OnRender` methods, plus `CombatControl::RenderTarget`, so windows and target graphics do not contaminate the headset HUD. Normal capture remains unchanged. Gameplay crops the upper native status and crew region to a fixed-size headset-relative panel, omitting the lower strip; Power/Shortcuts/the wheel supply those actions. Main menus, defeat/victory results and desktop Tactical/map views retain full framing.
[hud_anchor.gd](../scripts/hud_anchor.gd) gives the headset-relative pose priority and keeps fixed metre dimensions for readable text. Collision checks use the transformed hull/shield envelope and the panel's swept movement. Only a potential intersection engages the above-ship clamp, with 8 cm clearance; looking down cannot pass the HUD through the ship. The panel returns to headset-relative placement when clearance permits. Pause is attached above the same HUD panel.
Result detection reads the native `GameOver` window's inherited `FocusWindow` open flag. Hook resolution validates `CommandGui`'s GameOver offset and the FocusWindow flag against matching signatures; the bridge bounds these read-only probes. A result state hides the encounter, combat effects, locks, controller panel and wheel, leaving the full original result screen interactive. A pending event freeze is still distinct from a result window.
[hud_anchor.gd](../scripts/hud_anchor.gd) gives the headset-relative pose priority and keeps fixed metre dimensions for readable text. Collision checks use the transformed hull/shield envelope and the panel's swept movement. Only a potential intersection engages the above-ship clamp, with 5 cm clearance; looking down cannot pass the HUD through the ship. The panel returns to headset-relative placement when clearance permits. Pause is attached above the same HUD panel.
Transport uses atomic raw RGBA files with an `FVR1` header, dimensions, sequence and timestamp. Tactical is captured at a target **30 Hz / 960×540**; map/windows retain **1280×720**. The independent HUD targets **10 Hz / 1280×720**. Native input coordinates remain **1280×720** regardless of capture resolution. PNG previews update at about 1 Hz and are diagnostics rather than the live transport.
+8 -4
View File
@@ -12,7 +12,7 @@ Open **Controls / Help** on the left panel for an illustrated controller diagram
|---|---|
| Right trigger | Click native HUD/menu controls, select crew, order movement or target a room |
| Right trigger held on crew | Grab a movement preview; release over a room on that crew's current ship to order movement |
| Right trigger held for a beam | Start at one enemy room and release at the beam's end room |
| Right trigger held for a beam | Place the start on the enemy deck plane, then release at the endpoint; points may lie between rooms |
| Right B | Native right click on a pointed HUD control; cancel selection/grab/wheel over space |
| Right A | Toggle enemy room inspection; change category when the wheel is open |
| Right grip, held | Hold the game's Shift modifier |
@@ -20,7 +20,8 @@ Open **Controls / Help** on the left panel for an illustrated controller diagram
| Right bumper, held | Open the shortcut wheel |
| Right stick direction | Select a wheel slot; release bumper to confirm |
| Left Dpad Down | Open or close Tactical |
| Left View | Pause or resume |
| Right Pause / Menu | Pause or resume |
| Left View | Hide or show the controller panel |
| Left Dpad Up | Open or close System Power |
| Left Dpad Left / Right | Previous / next hand-screen page |
| Left grip, held + hand motion | Move the encounter |
@@ -31,6 +32,8 @@ Open **Controls / Help** on the left panel for an illustrated controller diagram
The page order is **Navigation → Tactical → Shortcuts → System Power → Jump → Help**. Navigation includes a direct **SYSTEM POWER** button as well as Shortcuts. Point at an available panel button with the right controller and pull the right trigger. Jump, Ship and Store are offered according to native game availability.
On Steam Frame, use the physical right **Pause / Menu** button to pause. Left **View** hides or restores the controller panel so it can be cleared from your view. Fallback controller profiles may expose these actions on different buttons; check the in-VR Help and active interaction profile.
## System Power page
This page changes the contextual controls below. **Steam Frame X/Y are on the right controller.**
@@ -51,6 +54,7 @@ The game decides whether a change is allowed. One engine step changes one power
- Release over empty space to keep the selection, or use B to cancel the grab.
- Trigger-click a door or airlock to toggle it. Native locked, disabled and hacked states still apply.
- Select a weapon or targeting system first, then point at its allowed ship and trigger-click a room. Mind control, hacking and teleporters use native targeting rules, including power, cooldown and sensor restrictions.
- For a beam weapon, hold the trigger at its start point and release at its end point on the enemy deck plane. Room centers, floor snapping and room boundaries do not constrain these endpoints; a line may cross gaps between rooms. FTL still determines the beam's length, accepted aim and damage.
- Tactical retains the original 2D view while allowing 3D crew, room and door interaction.
Enemy inspection reveals room roles; it does not reveal crew or hazards that FTL's sensors hide.
@@ -86,9 +90,9 @@ These are native key equivalents shown on buttons, not extra physical controller
During an actual native jump, surrounding stars stretch along your ship's bow. Turning your head does not change the travel direction. Arrival restores normal stars, including when the arrival dialog opens. Opening the map or charging FTL alone does not start the stretch.
Events, Store (including BUY/SELL), upgrades, crew and equipment windows float above the player ship. Their original localized controls stay interactive. Main menus remain complete, head-relative 2D views.
Events, Store (including BUY/SELL), upgrades, crew and equipment windows float above the player ship. Their original localized controls stay interactive. Main menus and native defeat/victory results use the complete, head-relative 2D screen. On a result screen, the encounter, target marks, combat effects and controller panel are cleared from view.
Gameplay uses a **compact HUD anchored to the headset**, retaining native upper status and the crew roster while removing the lower weapons/reactor/system/subsystem strip. Use Power, Shortcuts or the wheel for those lower-strip actions. The HUD keeps a fixed readable size and follows the headset whenever there is room. If that movement would carry the panel into the ship or shield, it stops 8 cm above the ship until clearance permits headset-relative placement again. A raised or oversized table can therefore raise the panel during overlap. The hand panel retains visual and pointing priority where it overlaps the HUD. Ship, shop, pause-menu and Options windows opened during a run appear only above the ship; point at that floating panel to use them. Initial/hangar menus and desktop Tactical/map inspection keep the complete native view.
Gameplay uses a **compact HUD anchored to the headset**, retaining native upper status and the crew roster while removing the lower weapons/reactor/system/subsystem strip. Use Power, Shortcuts or the wheel for those lower-strip actions. The HUD keeps a fixed readable size and follows the headset whenever there is room. If that movement would carry the panel into the ship or shield, it stops 5 cm above the ship until clearance permits headset-relative placement again. A raised or oversized table can therefore raise the panel during overlap. The hand panel retains visual and pointing priority where it overlaps the HUD. Ship, shop, pause-menu and Options windows opened during a run appear only above the ship; point at that floating panel to use them. Initial/hangar menus, results and desktop Tactical/map inspection keep the complete native view.
The native intruder warning keeps its lettering and subtle shadow/fringe on a transparent backdrop; neighboring HUD controls remain visible and pointable.
+8 -3
View File
@@ -29,13 +29,13 @@ After installing the documented Python requirements, run from the repository roo
python -m unittest discover -s tools -p "test_*.py"
```
The current suite passed **44 Python tests**, including empty native drone-list, portable-launcher and modal-HUD hook/preflight regressions. The tests exercise synthetic archive/layout/font extraction, bridge schema/input translation, native-pixel filtering, raw frame publication and launcher preflight/error handling. They do not launch FTL. Temporary fixture files are created by the tests. Final Godot and fresh native-game checks for the latest targeting/jump/drone batch are pending; earlier results and focused drone fixture checks are recorded in [STATUS](STATUS.md). Physical headset coverage remains separate.
The current correction passed **46 Python tests**, including empty native drone-list, portable-launcher, modal-HUD hook/preflight and native result-window regressions. The tests exercise synthetic archive/layout/font extraction, bridge schema/input translation, native-pixel filtering, raw frame publication and launcher preflight/error handling. They do not launch FTL. Temporary fixture files are created by the tests. Native beam placement/queued-shot acceptance and defeat/main-menu return pass; live native beam/miss outcomes remain unverified. Production state is restored with preserved campaign hashes; development desktop launch/exit and SteamVR preflight pass. Both standalone wrapper checks and source-only package checks pass; see the current delivery evidence in STATUS. Recorded evidence is in [STATUS](STATUS.md). Physical headset coverage remains separate.
## Godot checks
Use a **separate development checkout or disposable local-data folder** with the required extracted assets. Some suites write screenshots/test frames under `local_game_data/`; do not run them over an active production bridge or package those outputs. Create the local output folder before graphical suites if it is missing.
The commands below list eleven suites, including the new target-lock and drone resource/variant checks. The preceding HUD/wheel/bar revision passed nine suites; the following clearance/menu correction reran focused Vulkan HUD-layout and input checks plus native before/after capture checks. The latest batch's final combined Godot run and fresh native proof are pending. See [STATUS](STATUS.md) for evidence and limits. A fresh source checkout lacks game assets; model/input/font checks may fail until local extraction is complete. Existing installations should also rerun [asset extraction](INSTALLATION.md#5-extract-local-presentation-assets) to import the eight placed reticle PNGs.
The commands below list twelve suites. The current correction passed eight headless suites and four graphical Vulkan suites, including the new ship-status render check. See [STATUS](STATUS.md) for evidence and remaining native/delivery limits. A fresh source checkout lacks game assets; model/input/font checks may fail until local extraction is complete. Existing installations should also rerun [asset extraction](INSTALLATION.md#5-extract-local-presentation-assets) to import the eight placed reticle PNGs, and refresh [native hooks](INSTALLATION.md#4-resolve-the-local-executable-hooks).
```powershell
godot --headless --xr-mode off --path . --script tools/test_combat.gd -- --demo
@@ -47,14 +47,17 @@ godot --headless --xr-mode off --path . --script tools/test_environment.gd -- --
godot --xr-mode off --path . --script tools/test_ui.gd -- --desktop
godot --xr-mode off --path . --script tools/test_controller_ui.gd -- --demo --desktop
godot --xr-mode off --path . --script tools/test_frame_transport.gd -- --desktop
godot --xr-mode off --path . --script tools/test_ship_status_render.gd
godot --headless --xr-mode off --path . --script tools/test_hud_layout.gd -- --desktop
godot --headless --xr-mode off --path . --script tools/test_damage_visuals.gd -- --demo --desktop
```
The UI/controller/transport suites use actual GPU rendering, so keep their graphical mode. Headless runs of them are not equivalent verification. These suites cover effects, room/beam picking, controller context/bindings, model/task distinctions, environmental presentation, original local fonts, UI priority/crops and the raw frame protocol. HUD-layout checks cover headset-relative placement, swept collision prevention and transformed ship/shield clearance. Controller checks include native empty/malformed equipment collections. Damage-visual checks cover permission-aware room colors, absence of floor status bars, native miss presentation and oxygen-dependent breach effects.
The UI/controller/transport/ship-status suites use actual GPU rendering, so keep their graphical mode. Headless runs of them are not equivalent verification. The ship-status suite needs owner-local extracted ship assets; its default state is a labelled synthetic renderer fixture using `rebel_long`. To inspect a recorded native enemy instead, add `-- --state=res://local_game_data/recorded-enemy.json --output=res://local_game_data/ship-status`. The corresponding layout/art must also be extracted. Neither mode drives FTL. These suites cover effects, room/beam picking, controller context/bindings, model/task distinctions, environmental presentation, original local fonts, UI priority/crops and the raw frame protocol. HUD-layout checks cover headset-relative placement, swept collision prevention and transformed ship/shield clearance. Controller checks include native empty/malformed equipment collections. Damage-visual checks cover permission-aware room colors, absence of floor status bars, native miss presentation and oxygen-dependent breach effects.
Target-lock checks cover numbered player-owned native targets, autofire color, beam endpoint direction, pinpoint beams, flak radius, transformed/pause attachment, receiver visibility and cleanup. Drone checks cover distinct Mk II hardware, shared immutable body/exhaust/emitter geometry and independent emission state. Combat checks include native drone muzzle/render-space placement, fixed bullet launch points, continuing beams and authoritative progress/pause/outcome handling. Jump checks use native `jumping` and arrival-dialog fixtures; the action-wheel context checks include resetting each opening to weapons/drones.
For the current correction, cover one-HP hull cells for differing enemy hull maxima; separate artillery mounts and native shot origins; shield shutdown/charge and native cloak restoration; forward-only miss progress; free beam deck/gap endpoints under encounter transforms; complete result-screen cleanup; 5 cm HUD clearance; and the right Pause/Menu versus left View actions. Native checks must separately establish real artillery/shield/cloak state, accepted beam endpoints, result-window detection and supplemental target isolation. Rendered fixtures establish presentation behavior and must be labelled as fixtures.
No test command above drives a native game or modifies its saves. Separate **live bridge checks** do run the game and can advance a run or change equipment/state.
## Native integration checks
@@ -72,6 +75,8 @@ When adding a system or command, compare the visible native outcome and snapshot
A preceding native missed-event check used a private projectile and forced `Evasion.MISS` to exercise the actual flag/event path. It is integration evidence, not a measurement of random evasion probabilities. Enemy room condition colors respect the native visibility rules; floor health/status bars are no longer rendered. Do not infer permission to reveal other hidden state from public icon color or crew-room fog alone.
For the October 5 correction, native beam aim/release and its queued-shot transition are verified. The tested newly equipped and original saved missile/burst factories queued shots without releasing live projectiles, so this session does not establish a native beam sweep or missed-shot outcome. Keep that limit explicit and do not assign a speculative cause. Graphical miss fixtures prove forward presentation/cleanup only. Controlled Victory/defeat checks prove native result-screen integration, not a completed campaign.
The preparer, resolver and launcher reject unknown executable fingerprints. To support another executable, identify a known owned build, implement and validate a complete matching route and keep the rejection path. Do not disable fingerprint checks or reuse addresses from a different build.
## Profiling and visual inspection
+2 -2
View File
@@ -106,9 +106,9 @@ Use `CApp.zhl` from the matching **win32/1.6.9** directory of the Hyperspace sou
"C:\Games\FTL-VR-Lab\hooks.json"
```
Keep the entire signature directory together. The resolver also reads neighboring files such as `CEvent.zhl`, `TextInput.zhl`, `CommandGui.zhl`, `TabbedWindow.zhl`, `ChoiceBox.zhl`, `MouseControl.zhl`, `StarMap.zhl`, `MenuScreen.zhl`, `OptionsScreen.zhl`, `SystemControl.zhl`, `SystemBox.zhl` and `ShipSystem.zhl`. It checks unique signatures and structure probes, then stores the fingerprint and resolved addresses locally. Do not copy somebody else's `hooks.json` as a substitute.
Keep the entire signature directory together. The resolver also reads neighboring files such as `CEvent.zhl`, `TextInput.zhl`, `CommandGui.zhl`, `TabbedWindow.zhl`, `ChoiceBox.zhl`, `MouseControl.zhl`, `StarMap.zhl`, `MenuScreen.zhl`, `OptionsScreen.zhl`, `CombatControl.zhl`, `GameOver.zhl`, `FocusWindow.zhl`, `SystemControl.zhl`, `SystemBox.zhl` and `ShipSystem.zhl`. It checks unique signatures and structure probes, then stores the fingerprint and resolved addresses locally. Do not copy somebody else's `hooks.json` as a substitute.
**Updating an existing installation:** close the client and game, then rerun this step with the current `resolve_hooks.py` against your verified lab executable. The HUD/menu correction requires the new `MenuScreenOnRender` and `OptionsScreenOnRender` addresses. Keep the matching `MenuScreen.zhl` and `OptionsScreen.zhl` beside `CApp.zhl`. No game reinstall or save reset is needed. Run `CHECK-SETUP.cmd` afterward.
**Updating an existing installation:** close the client and game, then rerun this step with the current `resolve_hooks.py` against your verified lab executable. In addition to the earlier `MenuScreenOnRender` and `OptionsScreenOnRender` hooks, the current correction requires `CombatControlRenderTarget` and validated native result-window offsets. Keep matching `CombatControl.zhl`, `GameOver.zhl` and `FocusWindow.zhl` beside `CApp.zhl`, together with the earlier neighboring signatures. Run `CHECK-SETUP.cmd` afterward.
## 5. Extract local presentation assets
+29 -9
View File
@@ -1,6 +1,6 @@
# Project status
**Prepared source snapshot:** `0.1.0-alpha.1`, 2026-10-03, with subsequent gameplay refinements described below.
**Prepared source snapshot:** `0.1.0-alpha.1`, with source refinements through 2026-10-05 described below.
This is a playable experimental client connected to the real game. It is suitable for technical enthusiasts who can follow the source setup instructions and help test supported versions. A broad public release and a portable compiled installer are still future work.
@@ -10,23 +10,43 @@ This is a playable experimental client connected to the real game. It is suitabl
| --- | --- |
| Campaign | FTL runs its normal campaign, events, difficulty, combat rules, audio and saves |
| Ships | Dynamic local layouts, extruded hull presentation, rooms, weapon mounts, opposing bows in combat and stable movable encounter placement |
| HUD | Compact original status/crew panel follows the headset first at a fixed readable size; lower strip removed; clamps 8 cm above the transformed hull/shield only when its movement would intersect the ship; full main menus remain head-relative |
| HUD | Compact original status/crew panel follows the headset first at a fixed readable size; lower strip removed; clamps 5 cm above the transformed hull/shield only when its movement would intersect the ship; full main menus and results remain head-relative |
| Navigation | Available actions only; actual sector map appears above/ahead of the pilot room and faces the viewer |
| Menus/dialogs | Main menus remain 2D; in-run native shop/ship, pause-menu and Options windows appear only above the ship; event choices float in the encounter; BUY/SELL panels are retained |
| Menus/dialogs | Main menus and defeat/victory results retain the complete native 2D view; results clear the tabletop presentation; in-run shop/ship, pause-menu and Options windows appear only above the ship; event choices float in the encounter; BUY/SELL panels are retained |
| Crew | Native positions/tasks/selection, animated procedural race models, grab preview and native move orders; occupied crew face the viewer |
| Targeting | Weapon and beam room targeting; persistent player-owned numbered native locks with red/yellow autofire colors, actual beam endpoints/direction and flak spread radius; native mind-control, teleporter and hacking target routing |
| Targeting | Native weapon room targeting and freely placed beam endpoints across the enemy deck/gaps; persistent player-owned numbered locks with red/yellow autofire colors, actual beam direction and flak spread radius; native mind-control, teleporter and hacking target routing |
| Systems | Dpad Up and a Navigation button open System Power; native capped reactor availability, storm loss and battery availability remain separate |
| Doors | Open/closed, locked, damaged/hacked state and thin tier-dependent armor driven by native strength |
| Combat visuals | Shields/super shields, weapon charge strips/pips, native firing/impact/projectile paths, native miss cues/pass-by presentation and enemy hull bar using locally imported vanilla art |
| Combat visuals | Native charged/super shields and shutdown, native cloaking flag, separate artillery mounts, weapon charge strips/pips, native firing/impact paths, forward-moving missed shots and one hit point per enemy hull pip |
| Drones | Distinct procedural space/interior families and Combat/Beam/Defense Mk II hardware; deployment/power state, native render-space placement and visible muzzle shots with native progress/outcomes |
| Hazards | Room fire and cross-shaped breach tiles, oxygen-dependent air wisps, surrounding asteroid/sun/nebula/storm/pulsar presentations and native jump star stretch along the ship bow with arrival reset |
| Room feedback | Native role icons and permission-aware condition colors; no floor health/status bars or counters; normal sensor fog still applies |
| Input | Steam Frame actions, generic fallback profiles, controller infographic, equipment-name/family/ammo action wheel defaulting to weapons/drones on every opening, and QWERTY rename keyboard |
| Input | Steam Frame right Pause/Menu pauses and left View hides/shows the controller panel; generic fallback profiles, illustrated Help, equipment-name/family/ammo wheel defaulting to weapons/drones, and QWERTY rename keyboard |
| Lifecycle | Isolated game copy, fingerprinted hooks, separate `hs_ftlvr_` saves, pre-launch backup and graceful save/exit |
## Verification recorded during development
### Latest targeting, jump and drone refinements
### Latest combat, results and controller corrections — 2026-10-05
This batch corrects the enemy hull scale to one pip per native hit point; snapshots and renders artillery factories separately from regular weapon slots; uses native shield charge/shutdown and ship cloaking state; keeps missed shots progressing forward; and accepts beam endpoints on the enemy deck plane, including gaps between rooms. The native GameOver window selects the complete 2D result screen and clears tabletop ships, effects and interaction surfaces. The HUD collision clearance is 5 cm. Right Pause/Menu pauses; left View hides or restores the controller panel.
The supplemental HUD pass also suppresses `CombatControl::RenderTarget`, while the normal capture retains original target graphics. Native result-window detection is read-only and comes from fingerprint-validated `CommandGui`/`GameOver`/`FocusWindow` structure probes. Existing installations must refresh hooks as described in [Installation step 4](INSTALLATION.md#4-resolve-the-local-executable-hooks).
Current source passes **46 Python tests and twelve Godot suites**: eight headless suites plus graphical Vulkan UI, controller UI, frame transport and ship-status rendering. Five recorded native ship layouts keep every tested hull/keel corner inside the shield volume. GPU hull fixtures show exactly eight lit pips for both 8/15 and 8/22 hull. A five-frame missed-shot fixture progresses forward and clears with zero invented impacts.
Fresh copied-profile native checks find 4/3/2 artillery factories across the Flagship phases, with powered/firing state and enemy-owned first-phase shot origins. Actual first-phase native cloaking is observed. Every phase's snapshot shield center/radii exactly matches `Ship::GetBaseEllipse`; no geometry-center change is required. The shield correction propagates native shutdown and includes shutdown in the cached presentation state. A private call to the real native Victory function produces complete credits/result state with `end_screen=true`, `capture_full_screen=true`, `blocking=true` and no stale floating panel. The native result PNG was inspected; this is controlled integration evidence, not a completed campaign.
Native beam placement passes through the production release sequence: button-up followed by FTL's native confirmation click. The factory retains both freely placed off-center/gap points, in its reversed native endpoint order, with at most 0.00003 native-pixel error. Its native charge transition then consumes those targets into a queued shot. The newly equipped private fixture did not release a live beam projectile, so native beam sweep/damage campaign coverage remains open.
The Vulkan native-Victory presentation was inspected and passed with the full result screen and tabletop cleanup. A fresh native defeat check also passed: the full capture remained opaque, its Vulkan presentation was inspected, and the actual main-menu reset reported `ready=false`, `ui_mode=menu`, `end_screen=false`, `panel_open=false`. The native bridge exited with code 0 and no errors.
Live native beam projectile/sweep/damage and missed-shot outcomes remain unverified. Newly equipped private factories and the original saved missile/burst factories accumulated queued shots without emitting live projectiles in this QA session. The accepted aim/queue and rendered forward-miss fixtures establish their specific behavior; they do not establish those live outcomes.
Production archive/settings and the `ftlvr` save prefix are restored. Development and both standalone folders pass setup, SteamVR readiness and desktop Vulkan wrapper launch/exit with code 0, clean logs and disconnected error-free bridges. All four campaign hashes remain unchanged after final launches; both executable fingerprints, active/source Lua, regenerated hooks and absence of private QA events are verified. The matching 127-file source-only package passes publication checks with zero failures/warnings. Local source folders are updated; the verified source snapshot is recorded in Git history. Game assets/runtime/saves remain excluded and license notices are unchanged.
No physical Steam Frame test has been performed for this revision. These desktop/preflight checks do not establish physical controller coverage or stereo performance. Earlier checks below establish their recorded revisions only; complete campaign coverage and actual native beam/miss outcomes remain open.
### Preceding targeting, jump and drone refinements
The source now renders the player's native placed target state as numbered room locks, retaining autofire color, both beam endpoints and native flak radius. Marks remain attached through pause and encounter placement changes; missing targets, destroyed receivers and ended encounters remove them. Only player-owned weapon targets supply these marks. Jump stretch follows the transformed player ship bow and the native `jumping` flag; map opening/FTL charging do not start it. Arrival dialogs reset the sky even while a native jump flag is still clearing. Every wheel opening defaults to weapons/drones, with event choices retaining their separate context.
@@ -80,9 +100,9 @@ Production combined Vulkan launch/save/exit smoke passed: exit 0, clean client l
| Hacking | Enemy room targeting accepted; attachment/effect completion remains unverified |
| UI | Native HUD during Tactical/map/store/event/ship modes; enemy-region transparency; BUY/SELL content; rename input and original fonts checked across development revisions |
| Drones | Native equipment/special/global lists deduplicated; actual combat and interior battle-drone state inspected |
| Automated tests | Current 44 Python tests and eleven Godot suites pass; native normal/cleared/autofire targets, jump/arrival and warning blink captures pass; previous revision results are recorded above |
| Automated tests | Current 46 Python tests and twelve Godot suites pass; eight headless plus four graphical Vulkan suites |
| Desktop graphics | Vulkan rendering and model/UI inspection checked on RTX 4060 Ti 8 GB |
| Save handling | Fresh copied-profile native checks and final launch/exit smokes preserve all four campaign hashes; production prefix/archive/settings and both executable fingerprints are verified |
| Save handling | October 5 production restoration and final standalone launch/exit checks preserve all four campaign hashes and both executable fingerprints |
| Steam Frame play | Maintainer confirmed playability and substantial partial runs; latest changes need further hardware coverage |
The automated suite uses synthetic fixtures for many edge cases. A passing test does not establish that every race, layout, weapon, drone, hazard or boss encounter has been exercised in a campaign.
+4 -1
View File
@@ -18,7 +18,7 @@ Start with `CHECK-SETUP.cmd`, or `CHECK-SETUP.cmd --desktop` if testing without
| Lab destination rejected | Use a new empty separate directory outside the installed game. A nonempty unrelated directory is intentionally refused. |
| `Hyperspace.ftl`, loader DLL or ftlman error | Verify the full release folder structure and the ftlman executable/version described in Installation. |
| Resolver reports a missing/ambiguous signature | Use the matching win32/1.6.9 signature directory from Hyperspace source, with all neighboring `.zhl` files. Do not use other people's hook addresses. |
| “Regenerate lab hooks.json ... live native HUD capture” after updating | Close the game and rerun Installation step 4 with the current resolver. The matching signature directory must include `MenuScreen.zhl` and `OptionsScreen.zhl`. Then rerun `CHECK-SETUP.cmd`. |
| “Regenerate lab hooks.json ... live native HUD capture” after updating | Close the game and rerun Installation step 4 with the current resolver. Keep the complete matching signature directory, including `MenuScreen.zhl`, `OptionsScreen.zhl`, `CombatControl.zhl`, `GameOver.zhl` and `FocusWindow.zhl`. Then rerun `CHECK-SETUP.cmd`. |
| Recorded original installation unavailable | The bridge still reads the original `ftl.dat` for newly encountered ships. Restore that location or rebuild the lab/config against the new installation. |
To use a real Python installation outside PATH, set `FTLVR_PYTHON` to its executable before running `SETUP.cmd` (see Installation). These scripts do not use the Microsoft Store alias. Set `FTLVR_NO_PAUSE=1` only when calling the launchers from an automated script that captures their output; otherwise failures remain open so they can be read.
@@ -33,6 +33,9 @@ To use a real Python installation outside PATH, set `FTLVR_PYTHON` to its execut
| “Bridge already running” | Close the existing client and allow graceful shutdown before retrying. Do not start several sessions against one VR save profile. |
| Black or stale HUD/tactical image | Check `bridge_status.json` for fresh heartbeat/state/capture and errors. Check the logs. Do not judge the live Tactical stream from diagnostic PNGs: PNG previews update only once per second. |
| Ship, pause-menu or Options window appears both on the HUD and above the ship | Update this checkout's source and refresh its configured lab hooks using Installation step 4. Current capture keeps in-run windows only on the floating panel while retaining the status/crew HUD. |
| Controller panel has disappeared | Press left View to restore it on Steam Frame. Native defeat/victory results hide it together with the tabletop scene; use the original 2D result controls. |
| Left View no longer pauses | The current Steam Frame mapping uses the physical right Pause/Menu button for pause/resume. Left View hides/shows the controller panel. |
| Beam endpoint snaps to a room, or native target graphics appear on the compact HUD | Update this checkout's source and refresh its local hooks. Beam endpoints use the enemy deck plane, including gaps; supplemental capture excludes native target graphics while preserving the normal game screen. |
| Pointer missing or controller unresponsive | Confirm both controllers are tracked in SteamVR. Inspect the in-game warning, Help diagram and `xr_input.json` for each hand's tracking/profile/trigger values. Some fallback controller bindings remain untested. |
| Tactical looks slow | Report headset settings and whether the 3D scene, HUD or Tactical capture is slow. The live Tactical stream targets 30 Hz; mono desktop capture measurements are not stereo headset FPS. |
| Poor performance | Close unrelated GPU-intensive programs, record SteamVR resolution/refresh rate and note the encounter type. This alpha still needs physical stereo frame-pacing measurements and broad campaign QA. |
+49 -8
View File
@@ -17,6 +17,11 @@ var unit_beam_mesh: CylinderMesh
func spawn_shot(event: Dictionary, sender: Node3D, receiver: Node3D) -> void:
var projectile_id := str(event.get("projectile_id", ""))
for shot in shots:
if not projectile_id.is_empty() and shot["projectile_id"] == projectile_id:
# Repeated fire delivery cannot restart an already visible native flight.
return
var kind := str(event.get("kind", "laser"))
var origin_ship := sender
if event.has("origin_space") and int(event["origin_space"]) == int(receiver.get("enemy")):
@@ -24,6 +29,8 @@ func spawn_shot(event: Dictionary, sender: Node3D, receiver: Node3D) -> void:
if event.has("target_space") and int(event["target_space"]) == int(sender.get("enemy")):
receiver = sender
var start: Vector3 = sender.weapon_origin(int(event.get("weapon_slot", 0)))
if event.has("artillery_slot"):
start = sender.artillery_origin(int(event["artillery_slot"]))
var drone_id := str(event.get("drone_id", ""))
var drone: Node3D = _drone(origin_ship, drone_id)
if drone != null:
@@ -61,15 +68,17 @@ func spawn_shot(event: Dictionary, sender: Node3D, receiver: Node3D) -> void:
node.visible = false
# Bombs appear at their destination rather than crossing space.
shots.append({"node": node, "start": start, "end": end, "beam_end": beam_end,
"projectile_id": str(event.get("projectile_id", "")), "sender": sender,
"projectile_id": projectile_id, "sender": sender,
"created_ms": Time.get_ticks_msec(), "native_present": false,
"visual_scale": maxf(0.1, (origin_ship if not drone_id.is_empty() else sender).global_basis.get_scale().abs().x),
"live_progress": 0.0, "wanted_progress": 0.0, "missed": false, "miss_feedback": false,
"target_end": end, "miss_origin_progress": 0.0, "miss_interval": 1.0,
"end_point": event.get("end_point", {}),
"origin_point": event.get("origin_point", {}), "origin_ship": origin_ship,
"drone_id": drone_id, "launch_point": origin_ship.to_local(start),
"target_point": event.get("target", {}), "beam_point": {},
"slot": int(event.get("weapon_slot", 0)), "room": int(event.get("target_room", 0)),
"slot": int(event.get("weapon_slot", 0)), "artillery_slot": int(event.get("artillery_slot", -1)),
"room": int(event.get("target_room", 0)),
"time": 0.0, "duration": duration, "kind": kind, "outcome": outcome, "receiver": receiver})
@@ -116,13 +125,14 @@ func _process(delta: float) -> void:
var drone: Node3D = _drone(shot["origin_ship"], shot["drone_id"])
shot["start"] = drone.muzzle_position(shot["projectile_id"]) if shot["kind"] == "beam" and drone != null else shot["origin_ship"].to_global(shot["launch_point"])
else:
shot["start"] = shot["sender"].weapon_origin(shot["slot"])
shot["start"] = shot["sender"].artillery_origin(shot["artillery_slot"]) if shot["artillery_slot"] >= 0 else shot["sender"].weapon_origin(shot["slot"])
if shot["drone_id"].is_empty() and not shot["origin_point"].is_empty():
shot["start"] = shot["origin_ship"].to_global(shot["origin_ship"].pixel_point(shot["origin_point"]))
if shot["outcome"] == "pending":
shot["end"] = shot["receiver"].room_target(shot["room"])
if not shot["target_point"].is_empty():
shot["end"] = shot["receiver"].to_global(shot["receiver"].pixel_point(shot["target_point"]))
shot["target_end"] = shot["end"]
if shot["missed"]:
shot["end"] = _miss_endpoint(shot["start"], shot["end"], shot["receiver"])
if not shot["end_point"].is_empty():
@@ -130,7 +140,8 @@ func _process(delta: float) -> void:
var progress := clampf(float(shot["time"]) / float(shot["duration"]), 0.0, 1.0)
if shot["outcome"] == "pending":
if not simulation_paused:
shot["live_progress"] = lerpf(shot["live_progress"], shot["wanted_progress"], minf(1.0, delta * 20.0))
var next_progress := lerpf(shot["live_progress"], shot["wanted_progress"], minf(1.0, delta * 20.0))
shot["live_progress"] = next_progress if shot["kind"] == "beam" else maxf(shot["live_progress"], next_progress)
progress = shot["live_progress"]
var node: MeshInstance3D = shot["node"]
if shot["kind"] == "beam":
@@ -149,8 +160,10 @@ func _process(delta: float) -> void:
_segment(node.get_node("BeamCore"), shot["start"], beam_target, (0.007 if not shot["drone_id"].is_empty() else 0.012) * shot["visual_scale"])
_segment(node.get_node("BeamGlow"), shot["start"], beam_target, (0.018 if not shot["drone_id"].is_empty() else 0.032) * shot["visual_scale"])
elif shot["kind"] != "bomb":
node.global_position = Vector3(shot["start"]).lerp(shot["end"], progress)
node.global_position = _flight_position(shot, progress)
var direction: Vector3 = Vector3(shot["end"]) - Vector3(shot["start"])
if shot["outcome"] == "pending" and shot["missed"]:
direction = Vector3(shot["end"]) - Vector3(shot["start"]).lerp(shot["target_end"], shot["miss_origin_progress"])
if direction.length_squared() > 0.000001:
var up := Vector3.RIGHT if absf(direction.normalized().dot(Vector3.UP)) > 0.98 else Vector3.UP
node.global_basis = Basis.looking_at(direction.normalized(), up).scaled(Vector3.ONE * shot["visual_scale"])
@@ -178,7 +191,7 @@ func resolve_live(event: Dictionary, receiver: Node3D) -> void:
if event.get("outcome", "") == "miss":
# Evasion is reported at the native collision decision. The shot
# may still be flying; native snapshots decide when it disappears.
shots[i]["missed"] = true
_mark_missed(shots[i])
_show_shot_miss(shots[i])
return
kind = str(shots[i]["kind"])
@@ -208,8 +221,15 @@ func update_live_projectiles(projectiles: Array) -> void:
if present.has(shot["projectile_id"]):
var projectile: Dictionary = present[shot["projectile_id"]]
shot["native_present"] = true
shot["wanted_progress"] = float(projectile.get("progress", 0))
shot["missed"] = shot["missed"] or bool(projectile.get("missed", false))
if bool(projectile.get("missed", false)):
_mark_missed(shot)
var native_progress := maxf(0.0, float(projectile.get("progress", 0)))
if shot["kind"] == "beam" or not shot["missed"]:
native_progress = minf(1.0, native_progress)
# Older bridges measured remaining radial distance to the target; that
# value decreases again after a miss flies past it. Out-of-order native
# snapshots must likewise never pull a projectile back toward its sender.
shot["wanted_progress"] = native_progress if shot["kind"] == "beam" else maxf(shot["wanted_progress"], native_progress)
if shot["missed"]:
_show_shot_miss(shot)
shot["beam_point"] = projectile.get("beam_point", {})
@@ -222,6 +242,27 @@ func update_live_projectiles(projectiles: Array) -> void:
shots.remove_at(i)
func _mark_missed(shot: Dictionary) -> void:
if shot["missed"]:
return
shot["missed"] = true
shot["miss_origin_progress"] = float(shot["live_progress"])
shot["miss_interval"] = maxf(0.25, 1.0 - float(shot["miss_origin_progress"]))
func _flight_position(shot: Dictionary, progress: float) -> Vector3:
if shot["outcome"] != "pending" or not shot["missed"]:
return Vector3(shot["start"]).lerp(shot["end"], progress)
# Begin the passing path at the exact already-presented point. Switching to
# a native miss cannot jump the bullet to a different segment or reset it.
# New native progress may exceed one after passing the target; extrapolate
# forward until native absence, which ends the visual without an impact.
var origin_progress := float(shot["miss_origin_progress"])
var origin: Vector3 = Vector3(shot["start"]).lerp(shot["target_end"], origin_progress)
var passing_progress := maxf(0.0, progress - origin_progress) / float(shot["miss_interval"])
return origin.lerp(shot["end"], passing_progress)
func _impact(point: Vector3, kind: String = "laser", shield: bool = false) -> void:
var flash := MeshInstance3D.new()
var sphere_key := "large" if kind in ["missile", "bomb"] else "small"
+5 -1
View File
@@ -88,6 +88,8 @@ class HelpCanvas extends Control:
_button(Vector2(center_x + 31, 264), "A")
_button(Vector2(center_x - 39,222),"X",14)
_button(Vector2(center_x + 5,216),"Y",14)
draw_rect(Rect2(center_x + 27, 183, 24, 16), MUTED, false, 2.0)
_label("PAUSE", Vector2(center_x + 15, 175), 15, MUTED)
draw_line(Vector2(center_x - 28, 127), Vector2(center_x + 10, 132), GREEN, 6.0, true)
else:
draw_line(Vector2(center_x - 28,127),Vector2(center_x + 10,132),GREEN,6.0,true)
@@ -133,11 +135,13 @@ class HelpCanvas extends Control:
_badge("UP", Vector2(28, 394), 49)
_label("System Power", Vector2(91, 394), 20)
_badge("VIEW",Vector2(296,394),72)
_label("Pause",Vector2(380,394),20)
_label("Hide / show",Vector2(380,394),17)
_badge("L / R", Vector2(28, 425), 72)
_label("Previous / next screen", Vector2(112, 425), 20)
_badge("DOWN", Vector2(28, 456), 80)
_label("Tactical screen toggle", Vector2(121, 456), 20)
_badge("PAUSE", Vector2(526, 369), 85)
_label("Pause / resume", Vector2(626, 369), 20)
_badge("A", Vector2(526, 394))
_label("Inspect rooms / wheel category", Vector2(568, 394), 20)
_badge("B", Vector2(526, 425))
+46 -8
View File
@@ -5,6 +5,8 @@ const WIDTH := 0.64
const HEIGHT := 0.10
const PIXELS := Vector2i(770,130)
const HULL_COLOR := Color("85ff79")
const FILL_RECT := Rect2(34, 0, 720, 130)
static var native_contour: Texture2D
var viewport: SubViewport
var canvas: Control
var plate: MeshInstance3D
@@ -64,14 +66,18 @@ func face_viewer(viewer: Vector3) -> void:
func _draw() -> void:
var fraction := clampf(float(hull) / maxf(hull_max, 1), 0.0, 1.0)
var frame := UI.ASSETS.texture("img/statusUI/top_hull_red.png" if fraction <= .33 else "img/statusUI/top_hull.png")
var mask := UI.ASSETS.texture("img/statusUI/top_hull_bar_mask.png")
var mask := _hull_contour()
var label := UI.ASSETS.texture("img/statusUI/top_hull_red_label.png" if fraction <= .33 else "img/statusUI/top_hull_label.png")
# Use the actual vanilla stepped contour and segmented mask. Only owner-local
# textures are read; no FTL imagery is embedded in the distributable mod.
# Keep the owner's native stepped contour, but one pip means one hit point.
# The player mask's fixed thirty separators are replaced by hull_max cells.
if frame != null and mask != null and label != null:
canvas.draw_texture_rect(mask,Rect2(34,0,720,130),false,Color("253b32"))
if fraction > 0:
canvas.draw_texture_rect_region(mask,Rect2(34,0,720 * fraction,130),Rect2(0,0,360 * fraction,65),HULL_COLOR if fraction > .33 else UI.RED)
var source_size := mask.get_size()
for i in range(hull_max):
var cell := hull_cell_rect(i)
var source := Rect2((cell.position - FILL_RECT.position) * source_size / FILL_RECT.size,
cell.size * source_size / FILL_RECT.size)
canvas.draw_texture_rect_region(mask, cell, source,
(HULL_COLOR if fraction > .33 else UI.RED) if i < hull else Color("253b32"))
canvas.draw_texture_rect(frame,Rect2(0,0,770,130),false)
canvas.draw_texture_rect(label,Rect2(18,0,104,56),false)
else:
@@ -79,7 +85,39 @@ func _draw() -> void:
var outline := PackedVector2Array([Vector2(16,80),Vector2(16,42),Vector2(255,42),Vector2(267,32),Vector2(496,32),Vector2(506,24),Vector2(755,24),Vector2(755,80),Vector2(16,80)])
canvas.draw_colored_polygon(outline,UI.DARK)
canvas.draw_polyline(outline,UI.EDGE,2)
for i in range(30):
canvas.draw_rect(Rect2(26 + i * 24,52,21,22),HULL_COLOR if float(i) / 30 < fraction else Color("253b32"))
for i in range(hull_max):
var cell := hull_cell_rect(i)
canvas.draw_rect(Rect2(cell.position.x,52,cell.size.x,22),
(HULL_COLOR if fraction > .33 else UI.RED) if i < hull else Color("253b32"))
UI.text(canvas,"HULL",Vector2(28,4),29,UI.DARK,"header")
UI.text(canvas,"%d / %d" % [hull,hull_max],Vector2(639,0),28,UI.INK)
func hull_cell_rect(index: int) -> Rect2:
var width := FILL_RECT.size.x / maxf(hull_max, 1)
var gap := minf(3.0, width * 0.18)
return Rect2(FILL_RECT.position + Vector2(index * width, 0), Vector2(width - gap, FILL_RECT.size.y))
static func _hull_contour() -> Texture2D:
if native_contour != null:
return native_contour
var mask := UI.ASSETS.texture("img/statusUI/top_hull_bar_mask.png")
if mask == null:
return null
var image := mask.get_image()
if image == null or image.is_empty():
return null
# Fill the mask's old gaps row by row, retaining its native alpha boundary.
# This is cached once and does not alter the shared owner-local texture.
for y in range(image.get_height()):
var left := image.get_width()
var right := -1
for x in range(image.get_width()):
if image.get_pixel(x, y).a > 0.5:
left = mini(left, x)
right = maxi(right, x)
for x in range(left, right + 1):
image.set_pixel(x, y, Color.WHITE)
native_contour = ImageTexture.create_from_image(image)
return native_contour
+1 -1
View File
@@ -2,7 +2,7 @@ extends RefCounted
# Follow the headset first. Only the panel's path toward the ship engages the
# world-height stop, so a distant or side-on table never drags the HUD around.
const CLEARANCE := 0.08
const CLEARANCE := 0.05
const HEAD_OFFSET := Vector3(0.0, -0.12, -1.30)
const POSITION_TIME := 0.12
const PANEL_THICKNESS := 0.008
+122 -20
View File
@@ -42,6 +42,8 @@ var crew_selected := false
var weapon_selected := true
var trigger_was_down := false
var pause_was_down := false
var panel_toggle_was_down := false
var hand_screen_hidden := false
var cancel_was_down := false
var last_command := "Select a crew marker or target an enemy room"
var elapsed := 0.0
@@ -254,6 +256,8 @@ func _create_hud() -> void:
func _gameplay_hud_mode() -> bool:
if bool(hud_state.get("end_screen", false)):
return false
if not bool(hud_state.get("ready", demo_mode)):
return false
# In-run native windows have a separate world surface. A simultaneous
@@ -378,7 +382,7 @@ func _create_navigation() -> void:
legend.pixel_size = 0.001
legend.no_depth_test = true
legend.render_priority = 123
legend.text = "Dpad ◀ / ▶: screen ▼: tactical ▲: power\nView: pause R bumper: 1–8 wheel"
legend.text = "Dpad ◀ / ▶: screen ▼: tactical ▲: power\nR Pause: pause L View: hide screen R bumper: 1–8 wheel"
nav_panel.add_child(legend)
legend.visible = false
help_label = ControllerHelp.new()
@@ -616,7 +620,7 @@ func _change_system_power(direction: int) -> void:
func _update_power_controls(remove: bool, previous: bool, next: bool) -> void:
if nav_page == "power" and _game_actions_available():
if nav_page == "power" and _game_actions_available() and not beam_drag:
if previous and not power_previous_was_down:
panel_theme.step_selection(-1)
if next and not power_next_was_down:
@@ -744,10 +748,7 @@ func _process(delta: float) -> void:
if not trigger_down and trigger_was_down:
_release_pointer(ray.from, ray.direction)
trigger_was_down = trigger_down
var pause_down := left_hand.is_button_pressed("menu_button")
if pause_down and not pause_was_down:
_toggle_pause()
pause_was_down = pause_down
_update_pause_panel_buttons()
var cancel_down := right_hand.is_button_pressed("by_button")
if cancel_down and not cancel_was_down:
if wheel.visible:
@@ -811,11 +812,11 @@ func _process(delta: float) -> void:
func _face_navigation_to_headset(delta: float = 1.0 / 90.0) -> void:
if not left_hand.get_is_active():
nav_panel.visible = false
if not left_hand.get_is_active() or hand_screen_hidden or bool(hud_state.get("end_screen", false)):
_set_navigation_visible(false)
panel_filter.reset()
return
nav_panel.visible = true
_set_navigation_visible(true)
var point := left_hand.to_global(Vector3(0, 0.08, -0.11))
var toward := camera.get_camera_transform().origin - point
if toward.length_squared() > 0.00001:
@@ -824,6 +825,29 @@ func _face_navigation_to_headset(delta: float = 1.0 / 90.0) -> void:
nav_panel.global_transform = panel_filter.update(target, delta)
func _set_navigation_visible(shown: bool) -> void:
if nav_panel.visible == shown:
return
nav_panel.visible = shown
for button in nav_buttons:
var enabled: bool = shown and button.visible
button.collision_layer = 1 if enabled else 0
button.get_child(0).set_deferred("disabled", not enabled)
func _update_pause_panel_buttons() -> void:
# Frame's right Menu is its Pause button; left View only hides the hand UI.
var pause_down := right_hand.is_button_pressed("menu_button")
if pause_down and not pause_was_down:
_toggle_pause()
pause_was_down = pause_down
var panel_down := left_hand.is_button_pressed("menu_button")
if panel_down and not panel_toggle_was_down:
hand_screen_hidden = not hand_screen_hidden
_face_navigation_to_headset()
panel_toggle_was_down = panel_down
func _is_frame_hand(hand: XRController3D) -> bool:
var tracker := XRServer.get_tracker(hand.tracker) as XRControllerTracker
return tracker != null and "frame_controller" in str(tracker.profile)
@@ -1008,7 +1032,7 @@ func _select_from_ray(from: Vector3, direction: Vector3) -> void:
var room_hit := _target_room_hit(from, direction)
if not room_hit.is_empty():
beam_drag = str(_active_target().kind) == "weapon" and _selected_weapon_is_beam()
_command("target_room", {"room_id": int(room_hit.room_id), "ship": str(room_hit.ship), "phase": "down" if beam_drag else "click"})
_send_target_hit(room_hit, "down" if beam_drag else "click")
return
var query := PhysicsRayQueryParameters3D.create(from, from + direction.normalized() * 12.0)
query.collide_with_areas = true
@@ -1066,7 +1090,7 @@ func _command(action: String, data: Dictionary) -> void:
func _toggle_pause() -> void:
if rename_keyboard.visible:
if rename_keyboard.visible or bool(hud_state.get("end_screen", false)):
return
if demo_mode:
paused = not paused
@@ -1079,7 +1103,7 @@ func _update_hud() -> void:
if hud_surface == null:
return
var state := hud_state.duplicate(true)
var player_paused := bool(hud_state.get("paused", paused if demo_mode else false))
var player_paused := bool(hud_state.get("paused", paused if demo_mode else false)) and not bool(hud_state.get("end_screen", false))
state.merge({"hull": hull_current, "hull_max": hull_max, "shield": shield_level,
"reactor": reactor_power, "combat": battle, "paused": player_paused}, true)
hud_surface.set_state(state)
@@ -1087,8 +1111,8 @@ func _update_hud() -> void:
# Frozen event/store simulation is distinct from the player's pause toggle.
pause_label.text = "PAUSED" if player_paused else ""
pause_label.visible = bool(state.get("ready", demo_mode)) and not rename_keyboard.visible and player_paused
player_ship.set_shields(shield_level, int(state.get("super_shield", 0)))
enemy_ship.set_shields(int(state.get("enemy_shield", 2)), int(state.get("enemy_super_shield", 0)))
player_ship.set_shields(shield_level, int(state.get("super_shield", 0)), bool(state.get("player", {}).get("shield_shutdown", false)))
enemy_ship.set_shields(int(state.get("enemy_shield", 2)), int(state.get("enemy_super_shield", 0)), bool(state.get("enemy", {}).get("shield_shutdown", false)))
_sync_hud_layout()
_position_hud(0.0)
@@ -1138,6 +1162,9 @@ func _poll_bridge_state() -> void:
if not bridge_connected:
return
hud_state = state.duplicate(true)
if _apply_end_screen(state):
return
combat_effects.visible = true
_apply_text_entry(state.get("text_entry", {}))
space_environment.apply_state(state)
var event_open := bool(state.get("event_open", false))
@@ -1231,6 +1258,30 @@ func _poll_bridge_state() -> void:
combat_effects.update_live_projectiles(state.get("projectiles", []))
func _apply_end_screen(state: Dictionary) -> bool:
if not bool(state.get("end_screen", false)):
return false
# A finished run owns the full native canvas. Stale combat/window state can
# survive its first snapshot; none of those surfaces should cover this page.
for surface in [world_surface, event_surface, map_surface, player_ship, enemy_ship, combat_effects]:
surface.visible = false
_set_navigation_visible(false)
rename_keyboard.set_entry({"active": false})
wheel.visible = false
wheel_committed = true
beam_drag = false
held_surface = null
pending_page = ""
_end_crew_grab()
target_locks.clear()
enemy_hull_bar.set_state({})
paused = false
hud_surface.visible = true
hud_surface.canvas.frame_file = "screen_frame.png"
_update_hud()
return true
func _apply_text_entry(entry: Dictionary) -> void:
rename_keyboard.set_entry(entry)
if rename_keyboard.visible:
@@ -1432,7 +1483,7 @@ func _navigation_entries() -> Array:
func _refresh_navigation() -> void:
if map_surface == null:
return
map_surface.visible = xr_active and nav_page != "help" and (hud_state.get("map_open", false) or hud_state.get("tactical", false))
map_surface.visible = xr_active and not bool(hud_state.get("end_screen", false)) and nav_page != "help" and (hud_state.get("map_open", false) or hud_state.get("tactical", false))
if hud_state.get("map_open", false):
if map_surface.get_parent() != self:
map_surface.reparent(self, false)
@@ -1457,8 +1508,9 @@ func _refresh_navigation() -> void:
for i in range(nav_buttons.size()):
var button := nav_buttons[i]
button.visible = i < entries.size()
button.collision_layer = 1 if button.visible else 0
button.get_child(0).set_deferred("disabled", not button.visible)
var enabled: bool = button.visible and nav_panel.is_visible_in_tree()
button.collision_layer = 1 if enabled else 0
button.get_child(0).set_deferred("disabled", not enabled)
if button.visible:
button.set_meta("action", entries[i][1])
button.get_child(1).text = entries[i][0]
@@ -1483,6 +1535,11 @@ func _hover_ui(from: Vector3, direction: Vector3) -> void:
if now - last_hover_time < 0.1:
return
last_hover_time = now
if beam_drag:
var beam_hit := _beam_deck_hit(from, direction, "enemy")
if not beam_hit.is_empty() and _surface_hit(from, direction).is_empty() and _hand_panel_hit(from, direction).is_empty():
_send_target_hit(beam_hit, "move")
return
var hit := _surface_hit(from, direction)
if not hit.is_empty():
var pixel: Vector2i = hit["pixel"]
@@ -1509,9 +1566,9 @@ func _release_pointer(from: Vector3, direction: Vector3) -> void:
_command("ui_mouse", {"x": held_pixel.x, "y": held_pixel.y, "phase": "up"})
held_surface = null
if beam_drag:
var hit := _room_hit(from, direction, "enemy", 0.012)
var hit := _beam_deck_hit(from, direction, "enemy")
if not hit.is_empty() and _surface_hit(from, direction).is_empty() and _hand_panel_hit(from, direction).is_empty() and rename_keyboard.ray_hit(from, direction).is_empty():
_command("target_room", {"room_id": int(hit.room_id), "ship": "enemy", "phase": "up"})
_send_target_hit(hit, "up")
else:
_command("cancel", {})
beam_drag = false
@@ -1549,13 +1606,58 @@ func _active_target() -> Dictionary:
func _target_room_hit(from: Vector3, direction: Vector3) -> Dictionary:
var closest := {}
var free_beam := str(_active_target().get("kind", "")) == "weapon" and _selected_weapon_is_beam()
for side in _active_target().get("ships", ["enemy"]):
var hit := _room_hit(from, direction, str(side), 0.012)
var hit := _beam_deck_hit(from, direction, str(side)) if free_beam else _room_hit(from, direction, str(side), 0.012)
if not hit.is_empty() and (closest.is_empty() or hit.distance < closest.distance):
closest = hit
return closest
func _send_target_hit(hit: Dictionary, phase: String) -> void:
var data := {"room_id": int(hit.get("room_id", -1)), "ship": str(hit.ship), "phase": phase}
if beam_drag and hit.has("pixel"):
data.point = hit.pixel.duplicate()
_command("target_room", data)
func _beam_deck_hit(from: Vector3, direction: Vector3, side: String) -> Dictionary:
var ship: Node3D = player_ship if side == "player" else enemy_ship
if not ship.is_visible_in_tree() or direction.length_squared() < 0.000001:
return {}
var local_from: Vector3 = ship.to_local(from)
var local_direction: Vector3 = ship.global_basis.inverse() * direction.normalized()
if absf(local_direction.y) < 0.000001:
return {}
var distance := (0.0775 - local_from.y) / local_direction.y
if distance < 0.0 or distance > 20.0:
return {}
var point := local_from + local_direction * distance
var deck_point := Vector2(point.x, point.z)
var bounds := Rect2()
var has_bounds := false
for rect: Rect2 in ship.room_bounds.values():
bounds = bounds.merge(rect) if has_bounds else rect
has_bounds = true
var image: Dictionary = ship.layout_data.get("image_rect", {})
if not image.is_empty():
var first: Vector2 = (Vector2(float(image.get("x", 0)), float(image.get("y", 0))) / 35.0 - ship.layout_center) * ShipModel.TILE
var size := Vector2(float(image.get("w", 0)), float(image.get("h", 0))) / 35.0 * ShipModel.TILE
var rect := Rect2(first, size)
bounds = bounds.merge(rect) if has_bounds else rect
has_bounds = true
if not has_bounds or not bounds.has_point(deck_point):
return {}
var room_id := -1
for id in ship.room_bounds:
if ship.room_bounds[id].has_point(deck_point):
room_id = int(id)
break
var native: Vector2 = deck_point / ShipModel.TILE + ship.layout_center + Vector2(float(ship.layout_data.get("x_offset", 0)), float(ship.layout_data.get("y_offset", 0)))
return {"position": ship.to_global(point), "room_position": ship.to_global(point), "distance": distance,
"room_id": room_id, "ship": side, "pixel": {"x": native.x * 35.0, "y": native.y * 35.0}}
func _begin_crew_grab(crew: Node3D, from: Vector3) -> void:
_end_crew_grab()
grabbed_crew = crew
+131 -6
View File
@@ -26,6 +26,7 @@ var room_nodes: Dictionary = {}
var room_bounds: Dictionary = {}
var room_visibility: Dictionary = {}
var weapon_nodes: Array[Node3D] = []
var artillery_nodes: Dictionary = {}
var door_nodes: Dictionary = {}
var drone_nodes: Dictionary = {}
var drop_target_room := -1
@@ -36,10 +37,16 @@ var live_data: Dictionary = {}
var inspect_rooms := false
var demo_crew_marker: Area3D
var native_shield_geometry: Dictionary = {}
var shield_state_cache := Vector2i(-1, -1)
var shield_state_cache := Vector3i(-1, -1, -1)
var room_hazards: Node3D
var viewer_position := Vector3.ZERO
var viewer_position_valid := false
var cloaked := false
var cloak_strength := 0.0
var cloak_target := 0.0
var cloak_native_progress := false
var cloak_initialized := false
var cloak_materials: Dictionary = {}
func build(name: String, is_enemy: bool) -> void:
@@ -53,6 +60,7 @@ func build(name: String, is_enemy: bool) -> void:
room_visibility.clear()
crew_nodes.clear()
weapon_nodes.clear()
artillery_nodes.clear()
door_nodes.clear()
drone_nodes.clear()
drop_target_room = -1
@@ -61,7 +69,13 @@ func build(name: String, is_enemy: bool) -> void:
shield_fit_padding = 1.0
demo_crew_marker = null
native_shield_geometry.clear()
shield_state_cache = Vector2i(-1, -1)
shield_state_cache = Vector3i(-1, -1, -1)
cloak_materials.clear()
cloaked = false
cloak_strength = 0.0
cloak_target = 0.0
cloak_native_progress = false
cloak_initialized = false
ship_name = name
enemy = is_enemy
var path := _data_path("%s.json" % name)
@@ -111,6 +125,7 @@ func _make_hull(layout: Dictionary, center: Vector2) -> void:
var texture_path := _data_path("%s.png" % ship_name)
if rect.is_empty() or not FileAccess.file_exists(texture_path):
var block := MeshInstance3D.new()
block.name = "Hull"
block.mesh = BoxMesh.new()
block.scale = Vector3(1.8, 0.07, 1.15)
block.material_override = _material(Color(0.28, 0.32, 0.43) if enemy else Color(0.28, 0.36, 0.52))
@@ -123,6 +138,7 @@ func _make_hull(layout: Dictionary, center: Vector2) -> void:
var quad := QuadMesh.new()
quad.size = Vector2(float(rect["w"]) / 35.0 * TILE, float(rect["h"]) / 35.0 * TILE)
var mesh := MeshInstance3D.new()
mesh.name = "HullDeck"
mesh.mesh = quad
mesh.position = Vector3((float(rect["x"]) / 35.0 + float(rect["w"]) / 70.0 - center.x) * TILE,
0.005, (float(rect["y"]) / 35.0 + float(rect["h"]) / 70.0 - center.y) * TILE)
@@ -514,6 +530,7 @@ func _make_weapon_mounts(layout: Dictionary, center: Vector2) -> void:
var gun := WeaponModel.new()
gun.position = point
gun.rotation.y = 0.0 if str(mount.get("rotate", "false")) == "true" else PI / 2.0
gun.scale.z = -1.0 if str(mount.get("mirror", "false")) == "true" else 1.0
gun.build({"kind": "laser", "powered": false})
add_child(gun)
weapon_nodes.append(gun)
@@ -521,6 +538,51 @@ func _make_weapon_mounts(layout: Dictionary, center: Vector2) -> void:
mount_points.append(Vector3(0.5, 0.14, 0.0))
func _mount_point(mount: Dictionary) -> Vector3:
# WeaponMount.position is relative to shipImage, whereas crew/rooms are
# already in graph pixels. Apply the image rectangle exactly once.
var rect: Dictionary = layout_data.get("image_rect", {})
return Vector3((float(rect.get("x", 0)) + float(mount.get("x", 0))) / 35.0 * TILE - layout_center.x * TILE,
0.14, (float(rect.get("y", 0)) + float(mount.get("y", 0))) / 35.0 * TILE - layout_center.y * TILE)
func _apply_weapon_pose(weapon: Node3D, data: Dictionary) -> void:
var mount: Dictionary = data.get("mount", {})
if not mount.is_empty():
weapon.position = _mount_point(mount)
if data.has("mount_rotate"):
weapon.rotation.y = 0.0 if bool(data.mount_rotate) else PI / 2.0
if data.has("mount_mirror"):
# The model fires along +X; mirroring native art flips its cross-axis.
weapon.scale.z = -1.0 if bool(data.mount_mirror) else 1.0
func _apply_artillery(rows: Array) -> void:
var present: Dictionary = {}
for row in rows:
if not row is Dictionary:
continue
var slot := int(row.get("slot", 0))
present[slot] = true
if not artillery_nodes.has(slot):
var gun := WeaponModel.new()
gun.name = "Artillery%d" % slot
gun.build(row)
add_child(gun)
artillery_nodes[slot] = gun
var weapon: Node3D = artillery_nodes[slot]
if weapon.kind != WeaponModel.canonical_kind(str(row.get("kind", "laser"))) or weapon.weapon_name != str(row.get("name", "")):
weapon.build(row)
_apply_weapon_pose(weapon, row)
weapon.set_live(row)
for slot in artillery_nodes.keys():
if not present.has(slot):
var weapon: Node3D = artillery_nodes[slot]
remove_child(weapon)
weapon.queue_free()
artillery_nodes.erase(slot)
func _make_shield(layout: Dictionary) -> void:
var ellipse: Array = layout.get("ellipse", [350, 220, 0, 0])
shield_radii = Vector3(float(ellipse[0]) / 35.0 * TILE, 0.48, float(ellipse[1]) / 35.0 * TILE)
@@ -549,15 +611,15 @@ func _make_shield(layout: Dictionary) -> void:
set_shields(2)
func set_shields(charge: int, super_shield: int = 0) -> void:
func set_shields(charge: int, super_shield: int = 0, shutdown: bool = false) -> void:
shield_charge = maxi(charge, 0)
if shield_shell == null:
return
var current := Vector2i(shield_charge, super_shield)
var current := Vector3i(shield_charge, super_shield, int(shutdown))
if current == shield_state_cache:
return
shield_state_cache = current
shield_shell.visible = shield_charge > 0 or super_shield > 0
shield_shell.visible = not shutdown and (shield_charge > 0 or super_shield > 0)
var material: ShaderMaterial = shield_shell.material_override
material.set_shader_parameter("shield_color", Color(0.23, 0.95, 0.35) if super_shield > 0 else Color(0.18, 0.58, 1.0))
material.set_shader_parameter("strength", clampf(shield_charge / 4.0, 0.25, 1.0))
@@ -622,6 +684,13 @@ func set_viewer_position(world_position: Vector3) -> void:
func _process(delta: float) -> void:
if not cloak_native_progress and not is_equal_approx(cloak_strength, cloak_target):
# Older snapshots provide only the actual native cloak toggle. Ease that
# presentation transition; first observations are applied immediately.
cloak_strength = move_toward(cloak_strength, cloak_target, delta * 4.0)
_restore_cloak_materials()
if cloak_strength > 0.001:
_apply_cloak_materials(self)
if not simulation_paused:
shield_flash = maxf(0.0, shield_flash - delta * 3.0)
if shield_shell != null:
@@ -645,6 +714,8 @@ func _process(delta: float) -> void:
if not simulation_paused:
for weapon in weapon_nodes:
weapon.animate(delta)
for weapon in artillery_nodes.values():
weapon.animate(delta)
for drone in drone_nodes.values():
drone.animate(delta)
if is_instance_valid(room_hazards):
@@ -667,6 +738,9 @@ func pixel_point(point: Dictionary, height: float = 0.18) -> Vector3:
func apply_live(data: Dictionary) -> void:
# Native room/weapon condition updates operate on their original materials.
# Cloaking then applies isolated copies, including newly spawned actors.
_restore_cloak_materials()
live_data = data
var system_rows: Dictionary = {}
for row in data.get("system_status", []):
@@ -749,13 +823,58 @@ func apply_live(data: Dictionary) -> void:
var weapon: Node3D = weapon_nodes[slot]
if weapon.kind != WeaponModel.canonical_kind(str(weapons[slot].get("kind", "laser"))) or weapon.weapon_name != str(weapons[slot].get("name", "")):
weapon.build(weapons[slot])
_apply_weapon_pose(weapon, weapons[slot])
mount_points[slot] = weapon.position
weapon.set_live(weapons[slot])
_apply_artillery(data.get("artillery", []))
_apply_doors(data.get("doors", []))
_apply_drones(data.get("drones", []))
if is_instance_valid(room_hazards):
room_hazards.set_live(self, data)
_apply_shield_shape(data.get("shield_shape", {}))
set_shields(int(data.get("shield", 0)), int(data.get("super_shield", 0)))
set_shields(int(data.get("shield", 0)), int(data.get("super_shield", 0)), bool(data.get("shield_shutdown", false)))
cloaked = bool(data.get("cloaked", false))
cloak_target = 1.0 if cloaked else 0.0
cloak_native_progress = data.has("cloak_progress")
if cloak_native_progress:
cloak_strength = clampf(float(data.cloak_progress), 0.0, 1.0)
elif not cloak_initialized:
cloak_strength = cloak_target
cloak_initialized = true
if cloak_strength > 0.001:
_apply_cloak_materials(self)
func _restore_cloak_materials() -> void:
for id in cloak_materials.keys():
var entry: Dictionary = cloak_materials[id]
var node: GeometryInstance3D = entry.node.get_ref()
if not is_instance_valid(node) or not is_ancestor_of(node):
cloak_materials.erase(id)
elif node.material_override == entry.faded:
node.material_override = entry.source
func _apply_cloak_materials(node: Node) -> void:
# Material alpha works in Vulkan mobile and stereo. GeometryInstance3D's
# transparency property is limited to Forward+, so it cannot drive this.
if node is GeometryInstance3D and node.material_override is StandardMaterial3D and node.name != "SystemIcon":
var source: StandardMaterial3D = node.material_override
var id := node.get_instance_id()
if not cloak_materials.has(id) or cloak_materials[id].source != source:
cloak_materials[id] = {"node": weakref(node), "source": source, "faded": source.duplicate()}
var material: StandardMaterial3D = cloak_materials[id].faded
var hull_part := node.name in ["Hull", "HullDeck", "ExtrudedHull"]
var alpha := lerpf(1.0, 0.375 if hull_part else 0.5, cloak_strength)
material.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
material.albedo_color = source.albedo_color
material.albedo_color.a *= alpha
material.emission_enabled = source.emission_enabled
material.emission = source.emission
material.emission_energy_multiplier = source.emission_energy_multiplier * alpha
node.material_override = material
for child in node.get_children():
_apply_cloak_materials(child)
func _room_visible(id: int) -> bool:
@@ -805,6 +924,12 @@ func weapon_origin(slot: int) -> Vector3:
return to_global(mount_points[posmod(slot, mount_points.size())])
func artillery_origin(slot: int) -> Vector3:
if artillery_nodes.has(slot):
return artillery_nodes[slot].global_position
return global_position
func room_target(room_id: int) -> Vector3:
return to_global(room_points.get(room_id, Vector3(0.0, 0.18, 0.0)))
+92
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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}]
+62
View File
@@ -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
+1 -1
View File
@@ -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)
+88
View File
@@ -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
+3 -3
View File
@@ -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))
+65
View File
@@ -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)
+177
View File
@@ -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)