commit 35cdf710547729fbb35604b1e2e6e8aa7869269f Author: ElHombreAlex Date: Sat Oct 3 12:59:33 2026 +0200 V0.1 First publishable version of the mod diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..c452169 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,13 @@ +* text=auto eol=lf +*.md text eol=lf +*.py text eol=lf +*.gd text eol=lf +*.gdshader text eol=lf +*.lua text eol=lf +*.js text eol=lf +*.json text eol=lf +*.yml text eol=lf +*.cmd text eol=crlf + +# Keep upstream notices and their recorded SHA-256 hashes stable on checkout. +licenses/* -text diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..829e7ed --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,44 @@ +name: Bug report +description: Report a reproducible setup, gameplay, input or rendering issue. +title: "[Bug] " +body: + - type: markdown + attributes: + value: | + Please check docs/TROUBLESHOOTING.md first. Do not attach game files, extracted assets, saves or unreviewed logs containing personal paths. Use small redacted log excerpts where useful. + - type: textarea + id: versions + attributes: + label: Versions and hardware + description: Windows, source revision, FTL source/lab version, Hyperspace, Godot, Python, SteamVR, headset/controllers and GPU. + validations: + required: true + - type: dropdown + id: mode + attributes: + label: Where does it happen? + options: + - Live VR + - Live desktop + - Setup/preflight + - Synthetic demo or source tests + validations: + required: true + - type: textarea + id: steps + attributes: + label: Steps to reproduce + description: Include the hand-screen page, buttons pressed, ship/system or encounter involved, and how often it occurs. + validations: + required: true + - type: textarea + id: result + attributes: + label: Expected and actual behavior + validations: + required: true + - type: textarea + id: evidence + attributes: + label: Redacted logs or measurements + description: For performance, say whether you measured headset FPS/frame times or Tactical texture updates. For tracking, include the interaction profile if known. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..0acf4db --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,22 @@ +name: Feature or compatibility request +description: Suggest a concrete improvement or offer to test another configuration. +title: "[Idea] " +body: + - type: textarea + id: problem + attributes: + label: What would this improve? + description: Describe the player situation, device or game interaction. + validations: + required: true + - type: textarea + id: proposal + attributes: + label: Suggested change + validations: + required: true + - type: textarea + id: testing + attributes: + label: Hardware or testing you can provide + description: Optional. Include exact versions for a compatibility request. diff --git a/.github/workflows/python-tests.yml b/.github/workflows/python-tests.yml new file mode 100644 index 0000000..aca5388 --- /dev/null +++ b/.github/workflows/python-tests.yml @@ -0,0 +1,23 @@ +name: Python source tests + +on: + push: + pull_request: + workflow_dispatch: + +permissions: + contents: read + +jobs: + unit-tests: + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.11' + cache: pip + - name: Install Python dependencies + run: python -m pip install -r requirements.txt + - name: Run synthetic source tests (no FTL installation required) + run: python -m unittest discover -s tools -p "test_*.py" -v diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..58055b7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,41 @@ +/.godot/ +/local_game_data/ +/exports/ +/.venv/ +/venv/ +/build/ +/dist/ +/third_party/ +/lab/ +/ftl-vr-lab/ +/save-backups/ +**/__pycache__/ +*.py[cod] +*.log +.pytest_cache/ +.coverage +.env +.env.* +*.env +!.env.example + +# Game files, prepared archives, executables and saves stay local. +*.exe +*.dll +*.dat +*.sav +*.pck +*.ftl +*.bps +*.zip +*.prepared +*.font +hooks.json +ftlvr-lab.json +settings.ini + +# Desktop/editor and local test outputs. +.DS_Store +Thumbs.db +/outputs/ +/captures/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..dfebe60 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,35 @@ +# Changelog + +## 0.1.0-alpha.1 — prepared source snapshot, 2026-10-03 + +The first standalone community source package. This records the current prototype; it does not claim a completed public release. + +### Core prototype + +- Live FTL campaign connected to a Godot OpenXR tabletop presentation through Hyperspace and fingerprinted native hooks. +- Controller pointing, crew movement, weapon/system targeting, door controls, native HUD and contextual navigation. +- Floating events, complete shop/ship panels, an action wheel, illustrated controller Help and QWERTY renaming. +- A dedicated System Power page and native Shift/Ctrl support. +- Procedural crew/weapon/drone models, shields, weapon charging, room hazards and surrounding environments. +- Isolated Steam-derived lab, VR save prefix, pre-launch backups and coordinated save/exit. + +### Latest gameplay revision included + +- Faster Tactical delivery through reduced raw RGBA frames and independently captured native HUD. +- Full-resolution maps/windows, reused textures and crops synchronized to newly opened panels. +- Lighter FTL-style controller UI with locally imported native bitmap fonts. +- Viewer-facing occupied crew, thin upgraded doors and refined drone families. +- Original enemy hull-bar presentation lowered toward the ship. +- Jump projection anchored above/ahead of each ship's actual piloting room. + +### Standalone packaging + +- Portable environment/launch helpers and an example local configuration. +- Community installation, controls, architecture, troubleshooting, development and status guides. +- GitHub issue forms and Windows Python-test workflow. +- Generated assets, dependencies, configuration, game copies, captures and saves excluded from Git. +- MIT source license preserving the universal-modder notice, external dependency notices and versioned license-reference documents. + +### Open verification + +Clean-machine setup, latest physical headset ergonomics/frame pacing, full campaign/boss coverage, hacking attachment, the complete GOG route and additional hardware compatibility. diff --git a/CHECK-SETUP.cmd b/CHECK-SETUP.cmd new file mode 100644 index 0000000..d7a2f6c --- /dev/null +++ b/CHECK-SETUP.cmd @@ -0,0 +1,9 @@ +@echo off +setlocal +cd /d "%~dp0" +if not exist ".venv\Scripts\python.exe" ( + echo Run SETUP.cmd first. This check does not install or launch anything. + exit /b 1 +) +".venv\Scripts\python.exe" tools\setup_environment.py --check %* +exit /b diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..9254104 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,52 @@ +# Contributing + +Thanks for helping improve this experimental tabletop VR presentation of FTL. Good contributions include reproducible bug reports, controller comfort feedback, focused fixes, original procedural models/effects, documentation and verified compatibility work. + +Read the [README](README.md), [project status](docs/STATUS.md) and [development guide](docs/DEVELOPMENT.md) before changing the bridge. The actual FTL process remains authoritative for gameplay. + +## Reporting a bug + +Include: + +- Repository revision and whether this was **live VR**, **live desktop** or **demo/fixture** mode. +- Windows, FTL store/build, prepared lab build, Hyperspace, Godot, Python and SteamVR versions. +- Headset/controllers, active OpenXR profile, GPU and RAM. +- Steps to reproduce, expected behavior and what happened. +- Active panel/page, pause/event/transition state and relevant installed systems. +- Whether it occurs without other FTL mods. +- Short redacted bridge/client log excerpts and state/capture freshness when relevant. +- For pointing issues: which hand/button, target type, panel overlap and whether tracking was valid. +- For performance: which measurement tool and whether the number is transport Hz, desktop FPS or actual stereo headset frame timing. + +Do not attach game executables, `ftl.dat`, extracted artwork/fonts/audio, full saves, decompiled game source or the complete local-data folder. Describe the encounter or use a minimal synthetic reproduction. Remove personal file paths before sharing logs. + +## Submitting a focused change + +1. Work in your own source checkout and isolated game lab. +2. Keep the change small enough to review and explain its expected native behavior. +3. Follow existing Python/GDScript/JavaScript/Lua style in the file you edit. +4. Run the relevant checks from [Development](docs/DEVELOPMENT.md). +5. Document verification, hardware/build/runtime and remaining limitations in the change description. +6. Update affected documentation and the in-VR controller diagram when bindings change. + +A renderer screenshot does not verify a native command, and a desktop frame rate does not verify VR performance. Be explicit about simulated versus physical controller testing. Native experiments can change the disposable run; restore your own backup when needed. + +## Technical boundaries + +- Keep input scoped to the verified isolated game process; avoid global desktop mouse/keyboard automation. +- Preserve the separate VR save prefix, backups and graceful exit behavior. +- Preserve executable fingerprint/version checks. Unknown builds need a validated implementation, not removal of rejection logic. +- Keep native damage, crew movement, targeting restrictions, sensor fog and system availability authoritative. +- Preserve 1280×720 native input coordinates when changing capture/display resolution. +- Avoid blocking the game's main thread and avoid per-frame texture/mesh allocation. +- Keep installer paths configurable. Do not commit personal machine paths or bundled third-party executables. + +## Source license and attribution + +Original contributions to this project's source are provided under its [MIT license](LICENSE). Retain existing copyright/license notices. Document the origin and terms of any third-party code you add in [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md), preserving that component's terms. FTL content and locally extracted resources are outside the project license and stay out of commits. + +## Community testing priorities + +The current priorities are physical Steam Frame comfort and input reliability, stereo frame pacing during busy/hazard encounters, complete campaigns, boarding/teleporters, hacking completion, beam/bomb encounters and additional ship/drone combinations. GOG asset extraction is supported; its complete executable/loader path and other headset hardware require separate validation. + +See [STATUS](docs/STATUS.md) for the current evidence and remaining work, and [CONTROLS](docs/CONTROLS.md) for the intended bindings. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..043c797 --- /dev/null +++ b/LICENSE @@ -0,0 +1,22 @@ +MIT License + +Copyright (c) 2026 FTL VR contributors +Copyright (c) 2026 Rehan and universal-modder contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE +OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..f7f6b79 --- /dev/null +++ b/README.md @@ -0,0 +1,104 @@ +# FTL VR + +**Command your ship from a floating tabletop in VR.** + +An experimental OpenXR mod for **FTL: Faster Than Light**. Walk around a miniature ship, direct crew into rooms, target an opposing ship, and use FTL's original HUD and menus with tracked controllers. + +> **Playable source alpha · Windows · SteamVR · Steam Frame tested** +> +> The real FTL process runs the campaign, combat, events, saves and soundtrack. The VR client presents that live game in Godot. Bring your own copy of FTL; game files and extracted assets are not included. + +## What it does + +- Floating player and enemy ships with opposing bows, room interiors, visible shields and movable encounter placement. +- Original headset-following HUD, with a compact controller screen for navigation, Tactical, shortcuts, system power and illustrated Help. +- Crew selection and drag-to-room orders, door controls, weapon/beam targeting and native system targeting. +- Contextual Jump/Ship/Store actions, a cockpit-anchored sector map, floating event dialogs and complete native shop/ship panels. +- An eight-slot action wheel, Shift/Ctrl shortcuts and a QWERTY VR keyboard for renaming crew and ships. +- Original procedural voxel crew, weapons and drones, weapon charge indicators, room fire and breach effects. +- Surrounding stars and 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. + +## Start here + +| Guide | Contents | +| --- | --- | +| [Installation](docs/INSTALLATION.md) | Required downloads, local environment, isolated game preparation and first launch | +| [Controls](docs/CONTROLS.md) | Steam Frame bindings, contextual controls, modifiers and desktop inspection | +| [Project status](docs/STATUS.md) | Implemented features, verification, limitations and remaining work | +| [Troubleshooting](docs/TROUBLESHOOTING.md) | Setup failures, tracking, capture, logs and save recovery | +| [Architecture](docs/ARCHITECTURE.md) | How FTL, Hyperspace, the bridge and Godot communicate | +| [Development](docs/DEVELOPMENT.md) | Source layout, tests and profiling | +| [Contributing](CONTRIBUTING.md) | Useful reports and changes | +| [GitHub setup](docs/GITHUB_SETUP.md) | Connect this folder to your own new GitHub repository | +| [Licenses and notices](THIRD_PARTY_NOTICES.md) | MIT project source, preserved upstream notices and external dependency terms | + +### Prerequisites + +These are the versions used for the verified setup, rather than a promise of compatibility with newer releases. + +| Component | Verified setup | +| --- | --- | +| Operating system | Windows | +| Owned game | Steam Windows FTL 1.6.14, copied and rolled back to 1.6.9 | +| Loader | Hyperspace 1.23.2 | +| Rollback | Official FTL-Version-Rollback `Steam-1.6.14.bps` | +| Mod manager | ftlman 0.7.4 | +| VR client engine | Godot 4.7.2 standard build | +| Python | 3.10 or newer, with [requirements.txt](requirements.txt) | +| VR runtime | SteamVR selected as the active OpenXR runtime | +| Headset/controllers | Steam Frame; other OpenXR profiles are present but need hardware testing | + +Development and desktop checks used an **RTX 4060 Ti 8 GB and 32 GB RAM**. This is a tested configuration, not a measured minimum specification. GOG asset extraction is supported; the complete GOG executable/loader route has not been verified. + +### Setup and daily launch + +This is a technical source release. **SETUP.cmd prepares Python and a local configuration template; it does not install the game loader or complete the game setup.** Follow the [installation guide](docs/INSTALLATION.md) to prepare your own isolated FTL copy, resolve hooks, import local assets and fill in the configuration. + +After installation: + +1. Run **CHECK-SETUP.cmd** to check the configured paths and prerequisites. +2. Start SteamVR and connect the headset/controllers. +3. Run **RUN-VR.cmd**. Use **RUN-DESKTOP.cmd** for desktop inspection. +4. Close the client normally to request FTL's save-and-exit operation. + +The [controls guide](docs/CONTROLS.md) explains crew movement, room targeting, the action wheel and the System Power page. The in-VR Help page includes a controller infographic. + +## Current state + +The maintainer has played substantial portions of runs on Steam Frame and confirmed that earlier revisions are playable. The latest revision improves Tactical delivery, native-font menus, crew orientation, doors, drones, the enemy hull bar and map placement. + +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. + +Latest recorded checks: **27 Python tests and seven Godot suites passed**. Tactical delivery reached about **28–29 updates/second in desktop scene checks**; this is texture delivery, not headset FPS. A clean installation on a different machine has not yet been tested. See [STATUS.md](docs/STATUS.md) for the full distinction between implemented, checked and still to test. + +## Repository layout + +```text +bridge/ Hyperspace Lua state/events and Frida native hooks +scripts/ Godot scene, input, HUD, panels, models/effects and shaders +tools/ Extraction, preparation, launch, tests and profiling +config/ Example configuration with placeholder paths +docs/ Community guides +.github/ Issue forms and Python test workflow +project.godot Open this source project in Godot +local_game_data/ Generated locally; ignored by Git +``` + +No standalone compiled release is included. Download third-party tools separately and generate the local game-dependent data from your owned installation. + +## License + +Project source is available under the [MIT license](LICENSE), retaining the universal-modder upstream notice. Dependency license-reference documents and credits are included in [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md) and [licenses/](licenses/README.md). FTL game content remains outside this source license; players supply their own game and generate local presentation data. + +## Credits + +FTL: Faster Than Light is created by **Subset Games**. This is an unofficial community project and is not affiliated with or endorsed by Subset Games or Valve. + +- [universal-modder](https://github.com/rehan-remade/universal-modder), by Rehan and contributors: the toolkit and example workspace from which this standalone source was prepared. +- [Hyperspace](https://github.com/FTL-Hyperspace/FTL-Hyperspace) and [FTL-Version-Rollback](https://github.com/FTL-Hyperspace/FTL-Version-Rollback): the loader, Lua API, documented signatures and rollback route. +- [ftlman](https://github.com/afishhh/ftlman), with [Slipstream Mod Manager](https://github.com/Vhati/Slipstream-Mod-Manager) as a resource-format reference. +- [Godot](https://godotengine.org/), [Frida](https://frida.re/), [Capstone](https://www.capstone-engine.org/), [Pillow](https://python-pillow.org/) and [NumPy](https://numpy.org/). +- [Valve's Steam Frame Godot guidance](https://partner.steamgames.com/doc/steamhardware/steamframe/engines/godot) and [controller documentation](https://partner.steamgames.com/doc/steamhardware/steamframe/input). + +Developed iteratively with **OpenAI Codex (GPT-6)**, with design direction and Steam Frame playtesting by the project maintainer. Crew/weapon/drone voxel geometry is procedural source code; no external generated-asset service was used. FTL artwork, icons, bitmap fonts and audio come from the player's local game, rather than this repository. diff --git a/RUN-DESKTOP.cmd b/RUN-DESKTOP.cmd new file mode 100644 index 0000000..9e63e36 --- /dev/null +++ b/RUN-DESKTOP.cmd @@ -0,0 +1,11 @@ +@echo off +setlocal +cd /d "%~dp0" +if not exist ".venv\Scripts\python.exe" ( + echo Run SETUP.cmd and complete docs\INSTALLATION.md first. + exit /b 1 +) +".venv\Scripts\python.exe" tools\setup_environment.py --check --desktop +if errorlevel 1 exit /b 1 +".venv\Scripts\python.exe" tools\launch.py --desktop +exit /b diff --git a/RUN-VR.cmd b/RUN-VR.cmd new file mode 100644 index 0000000..1e5da62 --- /dev/null +++ b/RUN-VR.cmd @@ -0,0 +1,11 @@ +@echo off +setlocal +cd /d "%~dp0" +if not exist ".venv\Scripts\python.exe" ( + echo Run SETUP.cmd and complete docs\INSTALLATION.md first. + exit /b 1 +) +".venv\Scripts\python.exe" tools\setup_environment.py --check +if errorlevel 1 exit /b 1 +".venv\Scripts\python.exe" tools\launch.py +exit /b diff --git a/SETUP.cmd b/SETUP.cmd new file mode 100644 index 0000000..d42b677 --- /dev/null +++ b/SETUP.cmd @@ -0,0 +1,20 @@ +@echo off +setlocal +cd /d "%~dp0" +if exist ".venv\Scripts\python.exe" ( + ".venv\Scripts\python.exe" tools\setup_environment.py %* + exit /b +) +where py >nul 2>nul +if not errorlevel 1 ( + py -3 tools\setup_environment.py %* + exit /b +) +where python >nul 2>nul +if not errorlevel 1 ( + python tools\setup_environment.py %* + exit /b +) +echo Python was not found. Install Python 3.10 or newer from python.org, including its launcher. +echo See docs\INSTALLATION.md. No game files have been changed. +exit /b 1 diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md new file mode 100644 index 0000000..bc85aee --- /dev/null +++ b/THIRD_PARTY_NOTICES.md @@ -0,0 +1,48 @@ +# Third-party notices + +## Project source + +The source code, documentation and original procedural model/effect code in this repository are provided under the [MIT license](LICENSE). The upstream **2026 Rehan and universal-modder contributors** copyright notice is retained in that license. An unchanged copy of the upstream notice is also in [licenses/UNIVERSAL-MODDER-LICENSE.txt](licenses/UNIVERSAL-MODDER-LICENSE.txt). + +The project was prepared from the FTL example workspace in [universal-modder](https://github.com/rehan-remade/universal-modder). Original contributions are credited to **FTL VR contributors**. Development used OpenAI Codex (GPT-6), with design direction and hardware playtesting by the maintainer. + +Third-party license documents included for reference retain their own terms. The MIT license for this project does not replace the terms of an external component. + +## External software and reference copies + +The following implementations are obtained separately by the player/developer. This repository includes our integration code and license-reference documents; it does not contain their executables, libraries or source trees. + +| Component | Inspected version | Terms recorded in the inspected artifact | Local reference | +| --- | --- | --- | --- | +| [Godot](https://godotengine.org/) | 4.7.2 standard Windows engine | MIT; additional licenses for engine components | [Engine license](licenses/GODOT-LICENSE.txt), [component copyrights and full license definitions](licenses/GODOT_COPYRIGHT.txt) | +| [Frida Python bindings](https://github.com/frida/frida-python) | 17.9.0 | wxWindows Library Licence 3.1, including its exception and reference to GNU Library GPL v2 or later | [COPYING](licenses/FRIDA-COPYING.txt) | +| [Capstone](https://www.capstone-engine.org/) | 5.0.9 Python distribution | BSD 3-Clause | [LICENSE](licenses/CAPSTONE-LICENSE.txt) | +| [Pillow](https://python-pillow.org/) | 12.3.0 installed distribution | MIT-CMU for Pillow; additional notices for bundled components | [Full distribution license file](licenses/PILLOW-LICENSE.txt) | +| [NumPy](https://numpy.org/) | 2.3.5 installed distribution | BSD 3-Clause for NumPy; additional notices including OpenBLAS, LAPACK and GCC runtime terms | [Full distribution license file](licenses/NUMPY-LICENSE.txt) | +| [FTL Hyperspace](https://github.com/FTL-Hyperspace/FTL-Hyperspace) | 1.23.2 | CC BY-SA 4.0 for Hyperspace; ZHL/Kilburn attribution and other component notices | [Upstream LICENSE.md](licenses/HYPERSPACE-LICENSE.md) | +| [ftlman](https://github.com/afishhh/ftlman) | 0.7.4 | GNU GPL version 2 text | [Upstream LICENSE](licenses/FTLMAN-LICENSE.txt) | + +The copies above come from the inspected source checkouts or installed distributions. Their provenance and SHA-256 hashes are recorded in [licenses/manifest.json](licenses/manifest.json). The Godot documents were obtained through the engine's license-information API, as described in [Godot's licensing guidance](https://docs.godotengine.org/en/stable/about/complying_with_licenses.html). + +Frida and Capstone are pinned in [requirements.txt](requirements.txt). Pillow and NumPy have minimum-version requirements, so a new installation can resolve to different versions. The copies identify the versions inspected during preparation; preserve the notices supplied with any actual distribution you bundle. + +Frida's copied `COPYING` contains the wxWindows exception and refers to the GNU Library GPL base text. The inspected wheel did not contain `COPYING.LIB`. The [GNU Library GPL v2 text](https://www.gnu.org/licenses/old-licenses/lgpl-2.0.html) is linked here for reference. Frida itself is not distributed in this source repository. + +Hyperspace's copied license retains its original relative links. Use its [inspected upstream source revision](https://github.com/FTL-Hyperspace/FTL-Hyperspace/blob/db570d728321a5a2c70add0988153373fd4ec08a/LICENSE.md) to follow those links. The hook resolver reads separately obtained Hyperspace signatures locally. The preparation tool constructs its resource package locally from the player's separately obtained Hyperspace release; that generated package is ignored by Git. + +## Other prerequisites and references + +- [Python](https://docs.python.org/3/license.html) is installed separately. Its terms and the notices of its bundled components remain with that installation. +- [FTL-Version-Rollback](https://github.com/FTL-Hyperspace/FTL-Version-Rollback) supplies the separately obtained rollback patch. No patch or resulting executable is redistributed here; this project does not grant rights to them. +- [Slipstream Mod Manager](https://github.com/Vhati/Slipstream-Mod-Manager) was a reference for archive-format documentation. Its implementation is not included in this repository. +- Valve's [Steam Frame Godot guidance](https://partner.steamgames.com/doc/steamhardware/steamframe/engines/godot) and [controller documentation](https://partner.steamgames.com/doc/steamhardware/steamframe/input) informed the OpenXR integration. SteamVR is obtained separately; no Valve runtime is included. + +## FTL content and names + +**FTL: Faster Than Light is created by Subset Games. Players must own and supply their own game.** + +The project license grants rights to this project's source; it grants no rights to FTL's executables, archives, artwork, character designs, fonts, music, saves or trademarks. These files are not part of this repository. Locally extracted or generated game-dependent data stays in ignored folders. FTL and Steam/Steam Frame names are used to identify compatibility; this project is unofficial and is not endorsed by Subset Games or Valve. + +## Future bundled releases + +A compiled release must preserve the notices and satisfy the terms of the **actual artifacts it includes**. These source-reference documents are not a complete audit of a future package. A matching Godot build can use the captured engine/component notices; changed builds need refreshed notices. Bundling ftlman, Hyperspace, Frida, Python or Python wheels also requires their artifact-specific notices and any applicable source/attribution obligations. Keep game files and locally generated game resource packages out of public releases. diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..d6711f8 --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +0.1.0-alpha.1 diff --git a/bridge/agent.js b/bridge/agent.js new file mode 100644 index 0000000..92bcb91 --- /dev/null +++ b/bridge/agent.js @@ -0,0 +1,554 @@ +// Runs only inside the fingerprinted, owned FTL lab process. +// RVAs are resolved from the Hyperspace project's documented CApp signatures. +const config = CONFIG_PLACEHOLDER; +if (Process.arch !== 'ia32') throw new Error('FTL lab must be x86'); +const base = Process.mainModule.base; +// Hyperspace 1.23.2 mistakenly matches its supported --force-opengl flag +// against the legacy -opengl warning. Acknowledge only that lab-owned dialog. +const user = Process.getModuleByName('user32.dll'); +const enumWindows = new NativeFunction(user.getExportByName('EnumWindows'),'int',['pointer','pointer'],'stdcall'); +const enumChildren = new NativeFunction(user.getExportByName('EnumChildWindows'),'int',['pointer','pointer','pointer'],'stdcall'); +const windowPid = new NativeFunction(user.getExportByName('GetWindowThreadProcessId'),'uint',['pointer','pointer'],'stdcall'); +const windowText = new NativeFunction(user.getExportByName('GetWindowTextW'),'int',['pointer','pointer','int'],'stdcall'); +const dialogId = new NativeFunction(user.getExportByName('GetDlgCtrlID'),'int',['pointer'],'stdcall'); +const sendMessage = new NativeFunction(user.getExportByName('SendMessageW'),'pointer',['pointer','uint','pointer','pointer'],'stdcall'); +let warningText = '', warningButton = null; +const visitChild = new NativeCallback((h,l) => { + const text=Memory.alloc(4096); windowText(h,text,2048); + warningText += text.readUtf16String(); + if (dialogId(h)===100 && text.readUtf16String()==='OK') warningButton=h; + return 1; +},'int',['pointer','pointer'],'stdcall'); +const visitWindow = new NativeCallback((h,l) => { + const pid=Memory.alloc(4); windowPid(h,pid); + if (pid.readU32()!==Process.id) return 1; + warningText=''; warningButton=null; enumChildren(h,visitChild,ptr(0)); + if (warningButton!==null && warningText.includes('Startup argument `-opengl` is no longer supported!')) { + sendMessage(warningButton,0xF5,ptr(0),ptr(0)); + send({type:'renderer_warning_acknowledged'}); + } + return 1; +},'int',['pointer','pointer'],'stdcall'); +let warningChecks=0; +const warningTimer=setInterval(()=>{ + enumWindows(visitWindow,ptr(0)); + if (++warningChecks>=30) clearInterval(warningTimer); +},200); +const queue = []; +let nextInputTime = 0; +const methods = {}; +for (const name of ['OnLButtonDown','OnLButtonUp','OnRButtonDown','OnRButtonUp','OnKeyDown','OnKeyUp','OnTextInput','OnTextEvent']) { + const types = name.startsWith('OnKey') || name.startsWith('OnText') ? ['pointer','int'] : ['pointer','int','int']; + methods[name] = new NativeFunction(base.add(config.rvas[name]), 'void', types, 'thiscall'); +} +methods.OnMouseMove = new NativeFunction(base.add(config.rvas.OnMouseMove), 'void', + ['pointer','int','int','int','int','bool','bool','bool'], 'thiscall'); +let lastMouse = [0, 0]; +let leftHeld = false, rightHeld = false; +let app = null; +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; +const textStartCounter=Memory.alloc(4); +textStartCounter.writeU32(0); +const originalTextStart=Memory.alloc(Process.pointerSize); +// With self as the only argument, x86 fastcall and thiscall both use ECX. +// A direct native replacement also counts starts during input-handler reentry, +// where attached listeners are suppressed by the main-loop interceptor. +const textModule=new CModule(` +extern unsigned int entry_starts; +extern void *original_start; +typedef void (__attribute__((fastcall)) *StartFunction)(void *self); +void __attribute__((fastcall)) text_started(void *self) { + ++entry_starts; + ((StartFunction)original_start)(self); +} +void *__attribute__((fastcall)) text_abi_probe(void *self) { return self; } +`,{entry_starts:textStartCounter,original_start:originalTextStart}); +function textSnapshot() { + if (activeText === null) return {active:false}; + try { + if (activeText.start!==textStartCounter.readU32() || activeText.pointer.add(0x38).readU8() === 0) return {active:false}; + const start=activeText.pointer.add(0x18).readPointer(); + const end=activeText.pointer.add(0x1c).readPointer(); + const length=end.sub(start).toInt32()/4; + const maximum=activeText.pointer.add(0x40).readS32(); + if (!Number.isInteger(length) || length<0 || length>512 || maximum<0 || maximum>512) throw new Error('Invalid native text bounds'); + const characters=[]; + for (let i=0;i0x10ffff || (ch>=0xd800 && ch<=0xdfff)) throw new Error('Invalid native text codepoint'); + characters.push(ch); + } + return {active:true,id:activeText.id,kind:activeText.font===24?'ship':'crew', + text:String.fromCodePoint(...characters),original_text:activeText.original_text ?? '',max_length:maximum, + start_generation:activeText.start, + cursor:Math.max(0,Math.min(length,activeText.pointer.add(0x30).readS32()))}; + } catch (_) { activeText=null; return {active:false}; } +} +function reportText() { + const value=textSnapshot(); + const now=Date.now(); + if (now-lastTextReport>=100 || value.active!==textWasActive) { + send({type:'text_entry',value}); lastTextReport=now; textWasActive=value.active; + } +} +function attachText() { + if (!config.rvas.TextInputOnRender || !config.rvas.TextInputStart) throw new Error('Update lab hook manifest for native text entry'); + const probe=new NativeFunction(textModule.text_abi_probe,'pointer',['pointer'],'thiscall'); + if (!probe(textStartCounter).equals(textStartCounter)) throw new Error('Native TextInput start ABI mismatch'); + originalTextStart.writePointer(Interceptor.replaceFast(base.add(config.rvas.TextInputStart),textModule.text_started)); + Interceptor.attach(base.add(config.rvas.TextInputOnRender), { + onEnter() { + const pointer=this.context.ecx; + if (pointer.add(0x38).readU8()!==0) { + const start=textStartCounter.readU32(); + if (activeText===null || !pointer.equals(activeText.pointer) || activeText.start!==start || !textWasActive) { + activeText={pointer,id:'entry-'+(++textGeneration),font:0,start}; + activeText.original_text=textSnapshot().text ?? ''; + } + activeText.font=this.context.esp.add(4).readS32(); + reportText(); + } + } + }); +} +let inputModifiers = null; +const modifierMask = Memory.alloc(4); +modifierMask.writeS32(-1); +const originalShiftGetter = Memory.alloc(Process.pointerSize); +const originalCtrlGetter = Memory.alloc(Process.pointerSize); +// Use native stubs: JS callbacks are suppressed when re-entering native code +// from the main-loop Interceptor. The original getters run outside our input. +const modifierModule = new CModule(` +extern int modifier_mask; +extern void *original_shift; +extern void *original_ctrl; +int shift_state(void) { + if (modifier_mask >= 0) return (modifier_mask & 1) != 0; + return ((int (*)(void))original_shift)(); +} +int ctrl_state(void) { + if (modifier_mask >= 0) return (modifier_mask & 2) != 0; + return ((int (*)(void))original_ctrl)(); +} +`, {modifier_mask:modifierMask, original_shift:originalShiftGetter, original_ctrl:originalCtrlGetter}); +const focus = new NativeFunction(base.add(config.rvas.OnInputFocus), 'void', ['pointer'], 'thiscall'); +function move(x, y, left, right) { + methods.OnMouseMove(app, x, y, x-lastMouse[0], y-lastMouse[1], left ? 1 : 0, right ? 1 : 0, 0); + lastMouse = [x,y]; +} +function attachInput() { + if (!config.rvas.GetShiftState || !config.rvas.GetCtrlState || !config.rvas.ForceAutofireFlag) throw new Error('Update lab hook manifest for modifier support'); + originalShiftGetter.writePointer(Interceptor.replaceFast(base.add(config.rvas.GetShiftState), modifierModule.shift_state)); + originalCtrlGetter.writePointer(Interceptor.replaceFast(base.add(config.rvas.GetCtrlState), modifierModule.ctrl_state)); + Interceptor.attach(base.add(config.rvas.OnLoop), { + onEnter() { + this.perfStart=Date.now();capturePerformance.native_loops++; + app = this.context.ecx; + reportText(); + // 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); + for (const command of pending) { + // Native input may already have opened a rename field while a + // controller command is queued. Check again at execution, before + // changing modifiers or calling any gameplay handler. + let nativeTextActive=textSnapshot().active; + if (!nativeTextActive && activeText!==null) { + try { nativeTextActive=activeText.pointer.add(0x38).readU8()!==0; } catch (_) {} + } + const textCommand=['text','text_event','text_cancel'].includes(command.type); + const release=command.phase==='up' && ['mouse','move'].includes(command.type); + const exit=command.type==='key' && command.key===290; + if (nativeTextActive && !textCommand && !release && !exit) { + send({type:'ack',id:command.id,ignored:'native_text_entry_active'}); + continue; + } + // CApp::OnLButtonDown reads shift_held at +5 in this fingerprinted + // build. Scope both the field and getters to our input handler only. + const originalShift = app.add(5).readU8(); + const originalForce = config.rvas.ForceAutofireFlag ? base.add(config.rvas.ForceAutofireFlag).readU8() : null; + try { + inputModifiers = command.modifiers || {shift:false,control:false}; + modifierMask.writeS32((inputModifiers.shift ? 1 : 0) | (inputModifiers.control ? 2 : 0)); + app.add(5).writeU8(inputModifiers.shift ? 1 : 0); + // FTL also remembers force_autofire as a key-held flag. + if (inputModifiers.control) methods.OnKeyDown(app,306); + if (command.type === 'mouse') { + move(command.x,command.y,leftHeld,rightHeld); + const prefix = command.button === 'right' ? 'OnRButton' : 'OnLButton'; + if (command.phase !== 'up') { + methods[prefix+'Down'](app,command.x,command.y); + if (command.button === 'right') rightHeld=true; else leftHeld=true; + } + if (command.phase !== 'down') { + methods[prefix+'Up'](app,command.x,command.y); + if (command.button === 'right') rightHeld=false; else leftHeld=false; + } + } else if (command.type === 'move') { + move(command.x,command.y,command.left ?? leftHeld,command.right ?? rightHeld); + } else if (command.type === 'key') { + methods.OnKeyDown(app,command.key); + methods.OnKeyUp(app,command.key); + } else if (command.type === 'text' || command.type === 'text_event' || command.type === 'text_cancel') { + const entry=textSnapshot(); + if (!entry.active || !command.entry_id || command.entry_id!==entry.id) throw new Error('Native text entry changed'); + if (command.type === 'text') methods.OnTextInput(app,command.codepoint); + else if (command.type === 'text_cancel') { + // FTL's Escape commits the edited name. Restore the + // immutable initial text before its native cancel event. + // Keep the whole rollback on this native loop iteration. + methods.OnTextEvent(app,2); + for (const character of entry.original_text) methods.OnTextInput(app,character.codePointAt(0)); + methods.OnTextEvent(app,1); + } else methods.OnTextEvent(app,command.event); + } + send({type:'ack', id:command.id, modifiers:inputModifiers, + force_autofire_held:config.rvas.ForceAutofireFlag ? base.add(config.rvas.ForceAutofireFlag).readU8()!==0 : null}); + } catch (error) { send({type:'input_error',error:String(error),id:command.id}); } + finally { + if (inputModifiers && inputModifiers.control) methods.OnKeyUp(app,306); + if (originalForce !== null) base.add(config.rvas.ForceAutofireFlag).writeU8(originalForce); + app.add(5).writeU8(originalShift); modifierMask.writeS32(-1); inputModifiers=null; + } + } + }, + onLeave() { capturePerformance.loop_ms+=Date.now()-this.perfStart; } +}); } +rpc.exports = { + start() { attachWorldClip(); attachText(); attachHudPass(); attachInput(); attachCapture(); }, + capturemode(mode) { + hudEnabled=Boolean(mode.enabled); + capturePresentation=['game','tactical','window'].includes(mode.presentation)?mode.presentation:'window'; + nextScreenCapture=0; nextHudCapture=0; + }, + command(command) { + if (queue.length >= 120) throw new Error('Input queue full'); + let due=Math.max(Date.now(),nextInputTime); + if (command.type === 'mouse') { + // FTL computes some button hover states in OnLoop, between events. + queue.push({...command, type:'move',ready_at:due}); + due+=100; + if (!command.phase || command.phase === 'click') { + queue.push({...command, phase:'down',ready_at:due}, {...command, phase:'up',ready_at:due+40}); + due+=60; + } else queue.push({...command,ready_at:due}); + } else queue.push({...command,ready_at:due}); + nextInputTime=due+10; + }, + status() { return {pid:Process.id,mainThreadReady:app!==null,hudEnabled,gui:gui===null?null:String(gui), + hudFramebuffer,powerButtons,performance:{...capturePerformance,elapsed_ms:Date.now()-capturePerformance.started_ms}}; } +}; + +function attachWorldClip() { + // These signatures are from the 1.6.9 loader definitions. The Python + // bridge verifies the entire executable fingerprint before loading us. + const module = Process.mainModule; + function unique(pattern) { + const matches = Memory.scanSync(module.base, module.size, pattern); + if (matches.length !== 1) throw new Error('World clip signature is not unique: ' + pattern); + return matches[0].address; + } + const translate = unique('55 66 0f ef c0 b9'); + const push = unique('8d 4c 24 04 83 e4 f0 ff 71 fc 55 89 e5 51 83 ec 24 a1 ?? ?? ?? ?? 3b 05 ?? ?? ?? ?? 66 0f 6e 49'); + const pushScissor = new NativeFunction(push, 'void', ['int','int','int','int'], 'mscdecl'); + Interceptor.attach(translate, { + onEnter(args) { + // Float arguments occupy four-byte stack slots in this x86 ABI. + const x = this.context.esp.add(4).readFloat(); + const y = this.context.esp.add(8).readFloat(); + // ftlvr_bridge.lua's matching after callback pops this scissor. + if (x === -100000 && y === -100000) pushScissor(0, 0, 0, 0); + } + }); + send({type:'world_clip_ready'}); +} + +let gl = null; +let gui = null, hudEnabled = false, hudRender = null; +const hudPassFlag = Memory.alloc(4); +hudPassFlag.writeU32(0); +const powerButtons = {}; +let hudFramebuffer = 0, hudTexture = 0, hudDepthStencil = 0, hudWidth = 0, hudHeight = 0; +let hudModule = null; +let loadFramebufferGL = null; +let pixels = null; +let capacity = 0; +let capturePresentation='window'; +let nextScreenCapture=0,nextHudCapture=0; +let screenFramebuffer=0,screenTexture=0,screenWidth=0,screenHeight=0; +let sending = false; +let restoreVerified = false; +let screenRestoreVerified = false; +const viewport = Memory.alloc(16); +function initializeGL() { + if (gl !== null) return true; + const module = Process.findModuleByName('opengl32.dll'); + if (module === null) return false; + gl = { + get: new NativeFunction(module.getExportByName('glGetIntegerv'),'void',['uint','pointer'],'stdcall'), + read: new NativeFunction(module.getExportByName('glReadPixels'),'void',['int','int','int','int','uint','uint','pointer'],'stdcall'), + readBuffer: new NativeFunction(module.getExportByName('glReadBuffer'),'void',['uint'],'stdcall'), + getFloat: new NativeFunction(module.getExportByName('glGetFloatv'),'void',['uint','pointer'],'stdcall'), + pushAttrib: new NativeFunction(module.getExportByName('glPushAttrib'),'void',['uint'],'stdcall'), + popAttrib: new NativeFunction(module.getExportByName('glPopAttrib'),'void',[],'stdcall'), + matrixMode: new NativeFunction(module.getExportByName('glMatrixMode'),'void',['uint'],'stdcall'), + pushMatrix: new NativeFunction(module.getExportByName('glPushMatrix'),'void',[],'stdcall'), + popMatrix: new NativeFunction(module.getExportByName('glPopMatrix'),'void',[],'stdcall'), + clearColor: new NativeFunction(module.getExportByName('glClearColor'),'void',['float','float','float','float'],'stdcall'), + clear: new NativeFunction(module.getExportByName('glClear'),'void',['uint'],'stdcall'), + colorMask: new NativeFunction(module.getExportByName('glColorMask'),'void',['uchar','uchar','uchar','uchar'],'stdcall'), + disable: new NativeFunction(module.getExportByName('glDisable'),'void',['uint'],'stdcall'), + drawBuffer: new NativeFunction(module.getExportByName('glDrawBuffer'),'void',['uint'],'stdcall'), + genTextures: new NativeFunction(module.getExportByName('glGenTextures'),'void',['int','pointer'],'stdcall'), + bindTexture: new NativeFunction(module.getExportByName('glBindTexture'),'void',['uint','uint'],'stdcall'), + texImage: new NativeFunction(module.getExportByName('glTexImage2D'),'void',['uint','int','int','int','int','int','uint','uint','pointer'],'stdcall'), + texParameter: new NativeFunction(module.getExportByName('glTexParameteri'),'void',['uint','uint','int'],'stdcall'), + viewport: new NativeFunction(module.getExportByName('glViewport'),'void',['int','int','int','int'],'stdcall') + }; + const lookup=new NativeFunction(module.getExportByName('wglGetProcAddress'),'pointer',['pointer'],'stdcall'); + function extension(name,result,args) { + let address=lookup(Memory.allocUtf8String(name)); + if (address.isNull() || address.toInt32()===-1 || address.toUInt32()<=3) { + address=lookup(Memory.allocUtf8String(name+'EXT')); + } + if (address.isNull() || address.toInt32()===-1 || address.toUInt32()<=3) throw new Error('Missing native HUD framebuffer function '+name); + return new NativeFunction(address,result,args,'stdcall'); + } + loadFramebufferGL=()=>{ + if (gl.bindFramebuffer) return; + gl.genFramebuffers=extension('glGenFramebuffers','void',['int','pointer']); + gl.bindFramebuffer=extension('glBindFramebuffer','void',['uint','uint']); + gl.framebufferTexture=extension('glFramebufferTexture2D','void',['uint','uint','uint','uint','int']); + gl.checkFramebuffer=extension('glCheckFramebufferStatus','uint',['uint']); + gl.genRenderbuffers=extension('glGenRenderbuffers','void',['int','pointer']); + gl.bindRenderbuffer=extension('glBindRenderbuffer','void',['uint','uint']); + gl.renderbufferStorage=extension('glRenderbufferStorage','void',['uint','uint','int','int']); + gl.framebufferRenderbuffer=extension('glFramebufferRenderbuffer','void',['uint','uint','uint','uint']); + gl.blitFramebuffer=extension('glBlitFramebuffer','void',['int','int','int','int','int','int','int','int','uint','uint']); + }; + return true; +} + +function attachHudPass() { + if (!config.rvas.CommandGuiRenderStatic || !config.offsets) throw new Error('Update lab hooks for isolated native HUD rendering'); + hudRender=new NativeFunction(base.add(config.rvas.CommandGuiRenderStatic),'void',['pointer'],'thiscall'); + Interceptor.attach(base.add(config.rvas.CommandGuiRenderStatic),{onEnter() { + if (hudPassFlag.readU32()===0) gui=this.context.ecx; + }}); + // Native stubs remain active when reentering from the render interceptor. + // Only supplemental draw calls are suppressed; the normal game draw runs. + const symbols={hud_pass:hudPassFlag}; + let code='extern unsigned int hud_pass;\n'; + const names=['TabbedWindowOnRender','ChoiceBoxOnRender','MouseControlOnRender','CommandGuiRenderPause','StarMapOnRender']; + for (let i=0;isymbols['original_'+i].writePointer(Interceptor.replaceFast(base.add(config.rvas[name]),hudModule['draw_'+i]))); +} + +function nativePowerButtons(width,height) { + const buttons={}; + if (app===null || !hudEnabled) return buttons; + gui=app.add(config.offsets.app_gui).readPointer(); + if (gui.isNull()) throw new Error('Native gameplay GUI is absent'); + const control=gui.add(config.offsets.gui_system_control); + const origin=control.add(config.offsets.system_control_position); + const list=control.add(config.offsets.system_control_boxes); + const start=list.readPointer(),end=list.add(4).readPointer(); + const count=end.sub(start).toInt32()/Process.pointerSize; + if (!Number.isInteger(count) || count<0 || count>64) throw new Error('Invalid native power controls vector'); + for (let i=0;i15) continue; + const position=box.add(config.offsets.system_box_position); + // Native power allocation responds on the system icon, below its bars. + // This center was verified against actual add/remove native clicks. + const point={x:origin.readS32()+position.readS32()+32, + y:origin.add(4).readS32()+position.add(4).readS32()+33}; + if (point.x>=0 && point.y>=0 && point.x<1280 && point.y<720) { + buttons[String(id)]={x:point.x*width/1280,y:point.y*height/720}; + } + } + Object.keys(powerButtons).forEach(id=>delete powerButtons[id]); + Object.assign(powerButtons,buttons); + return buttons; +} + +function captureHud(width,height,size) { + if (!hudEnabled || gui===null || hudRender===null) return false; + loadFramebufferGL(); // wglGetProcAddress requires this game's current render context. + const previous=Memory.alloc(16); + gl.get(0x8CA6,previous); // framebuffer binding + gl.get(0x8CA7,previous.add(4)); // renderbuffer binding + gl.get(0x0BA0,previous.add(8)); // matrix mode + gl.pushAttrib(0x000fffff); // all server state, including viewport, buffers and texture bindings + gl.matrixMode(0x1700); gl.pushMatrix(); + gl.matrixMode(0x1701); gl.pushMatrix(); + try { + const generated=Memory.alloc(4); + if (!hudFramebuffer) { + gl.genFramebuffers(1,generated);hudFramebuffer=generated.readU32(); + gl.genTextures(1,generated);hudTexture=generated.readU32(); + gl.genRenderbuffers(1,generated);hudDepthStencil=generated.readU32(); + } + gl.bindFramebuffer(0x8D40,hudFramebuffer); + gl.bindTexture(0x0DE1,hudTexture); + if (hudWidth!==width || hudHeight!==height) { + gl.texImage(0x0DE1,0,0x8058,width,height,0,0x1908,0x1401,ptr(0)); // RGBA8 + gl.texParameter(0x0DE1,0x2801,0x2600);gl.texParameter(0x0DE1,0x2800,0x2600); + gl.framebufferTexture(0x8D40,0x8CE0,0x0DE1,hudTexture,0); + gl.bindRenderbuffer(0x8D41,hudDepthStencil); + gl.renderbufferStorage(0x8D41,0x88F0,width,height); // depth24 stencil8 + gl.framebufferRenderbuffer(0x8D40,0x821A,0x8D41,hudDepthStencil); + hudWidth=width;hudHeight=height; + } + if (gl.checkFramebuffer(0x8D40)!==0x8CD5) throw new Error('Native HUD framebuffer is incomplete'); + gl.viewport(0,0,width,height);gl.drawBuffer(0x8CE0);gl.readBuffer(0x8CE0); + gl.disable(0x0C11); // scissor + gl.colorMask(1,1,1,1);gl.clearColor(0,0,0,0);gl.clear(0x00004500); + gl.matrixMode(0x1700); + hudPassFlag.writeU32(1); + methods.OnKeyDown(app,287); // scoped F6: supplemental Lua render context + hudRender(gui); + gl.read(0,0,width,height,0x1908,0x1401,pixels); + send({type:'hud_frame',width,height,captured_at:Date.now()/1000},pixels.readByteArray(size)); + capturePerformance.hud_frames++;capturePerformance.bytes_sent+=size; + return true; + } finally { + methods.OnKeyDown(app,288); // scoped F7: always restore normal Lua rendering + hudPassFlag.writeU32(0); + gl.matrixMode(0x1701);gl.popMatrix();gl.matrixMode(0x1700);gl.popMatrix(); + gl.bindFramebuffer(0x8D40,previous.readU32()); + gl.bindRenderbuffer(0x8D41,previous.add(4).readU32()); + gl.popAttrib();gl.matrixMode(previous.add(8).readU32()); + } +} + +function readScreen(width,height,reduced) { + const outputWidth=reduced?960:width,outputHeight=reduced?540:height; + const previous=Memory.alloc(12); + gl.get(0x8CA6,previous);gl.get(0x8CAA,previous.add(4));gl.get(0x0C02,previous.add(8)); + const verify=reduced && config.verify_capture_restore && !screenRestoreVerified; + let original=null; + const originalViewport=Memory.alloc(16),originalScissor=Memory.alloc(16),originalScissorEnabled=Memory.alloc(4); + if (verify) { + gl.get(0x0BA2,originalViewport);gl.get(0x0C10,originalScissor);gl.get(0x0C11,originalScissorEnabled); + gl.readBuffer(0x0405);gl.read(0,0,width,height,0x1908,0x1401,pixels); + original=pixels.readByteArray(width*height*4);gl.readBuffer(previous.add(8).readU32()); + } + gl.pushAttrib(0x000fffff); + try { + if (reduced) { + loadFramebufferGL(); + const generated=Memory.alloc(4); + if (!screenFramebuffer) { + gl.genFramebuffers(1,generated);screenFramebuffer=generated.readU32(); + gl.genTextures(1,generated);screenTexture=generated.readU32(); + } + gl.bindFramebuffer(0x8CA9,screenFramebuffer); // draw framebuffer only + gl.bindTexture(0x0DE1,screenTexture); + if (screenWidth!==outputWidth || screenHeight!==outputHeight) { + gl.texImage(0x0DE1,0,0x8058,outputWidth,outputHeight,0,0x1908,0x1401,ptr(0)); + gl.texParameter(0x0DE1,0x2801,0x2600);gl.texParameter(0x0DE1,0x2800,0x2600); + gl.framebufferTexture(0x8CA9,0x8CE0,0x0DE1,screenTexture,0); + screenWidth=outputWidth;screenHeight=outputHeight; + } + if (gl.checkFramebuffer(0x8CA9)!==0x8CD5) throw new Error('Tactical framebuffer is incomplete'); + gl.bindFramebuffer(0x8CA8,0);gl.readBuffer(0x0405);gl.drawBuffer(0x8CE0); + gl.disable(0x0C11); // native world clip must not crop this full-screen blit + gl.blitFramebuffer(0,0,width,height,0,0,outputWidth,outputHeight,0x00004000,0x2600); + gl.bindFramebuffer(0x8CA8,screenFramebuffer);gl.readBuffer(0x8CE0); + } else { + gl.readBuffer(0x0405); + } + const started=Date.now(); + gl.read(0,0,outputWidth,outputHeight,0x1908,0x1401,pixels); + capturePerformance.read_ms+=Date.now()-started; + return {width:outputWidth,height:outputHeight,binary:pixels.readByteArray(outputWidth*outputHeight*4)}; + } finally { + // A HUD pass binds both targets; preserve them independently here. + if (gl.bindFramebuffer) { + gl.bindFramebuffer(0x8CA9,previous.readU32()); + gl.bindFramebuffer(0x8CA8,previous.add(4).readU32()); + } + gl.popAttrib();gl.readBuffer(previous.add(8).readU32()); + if (verify) { + const bindings=Memory.alloc(12),restoredViewport=Memory.alloc(16),restoredScissor=Memory.alloc(16),restoredScissorEnabled=Memory.alloc(4); + gl.get(0x8CA6,bindings);gl.get(0x8CAA,bindings.add(4));gl.get(0x0C02,bindings.add(8)); + gl.get(0x0BA2,restoredViewport);gl.get(0x0C10,restoredScissor);gl.get(0x0C11,restoredScissorEnabled); + let stateDifferences=0; + for (let i=0;i<3;i++) if (bindings.add(i*4).readU32()!==previous.add(i*4).readU32()) stateDifferences++; + for (let i=0;i<4;i++) { + if (restoredViewport.add(i*4).readS32()!==originalViewport.add(i*4).readS32()) stateDifferences++; + if (restoredScissor.add(i*4).readS32()!==originalScissor.add(i*4).readS32()) stateDifferences++; + } + if (restoredScissorEnabled.readU32()!==originalScissorEnabled.readU32()) stateDifferences++; + gl.readBuffer(0x0405);gl.read(0,0,width,height,0x1908,0x1401,pixels);gl.readBuffer(previous.add(8).readU32()); + const before=new Uint8Array(original),after=new Uint8Array(pixels.readByteArray(width*height*4)); + let differences=0;for (let i=0;i=nextScreenCapture; + const hudDue=hudEnabled && now>=nextHudCapture; + if ((!screenDue && !hudDue) || sending) return; + try { + gl.get(0x0BA2,viewport); // GL_VIEWPORT + const width=viewport.add(8).readS32(), height=viewport.add(12).readS32(); + if (width < 1 || height < 1 || width > 2560 || height > 1440) return; + const size = width*height*4; + if (size>capacity) { pixels=Memory.alloc(size); capacity=size; } + sending=true; + let original=null; + const verify=hudDue && config.verify_capture_restore && !restoreVerified; + if (screenDue || verify) original=readScreen(width,height,capturePresentation==='tactical' && !verify); + if (hudDue) { + const hudStart=Date.now(); + captureHud(width,height,size); + capturePerformance.hud_ms+=Date.now()-hudStart; + nextHudCapture=Math.max(nextHudCapture+100,now+1); + send({type:'system_power_buttons',buttons:nativePowerButtons(width,height),width,height}); + } + if (verify) { + const previousRead=Memory.alloc(4);gl.get(0x0C02,previousRead); + gl.readBuffer(0x0405);gl.read(0,0,width,height,0x1908,0x1401,pixels);gl.readBuffer(previousRead.readU32()); + const before=new Uint8Array(original.binary),after=new Uint8Array(pixels.readByteArray(size)); + let differences=0;for (let i=0;i 0 then + for i = 1, #value do list[#list+1] = json(value[i]) end + return '[' .. table.concat(list, ',') .. ']' + end + for key, member in pairs(value) do list[#list+1] = json(tostring(key)) .. ':' .. json(member) end + return '{' .. table.concat(list, ',') .. '}' +end + +local function attempt(label, callback, fallback) + local ok, result = pcall(callback) + if ok then return result end + if not errors[label] then log('FTLVR_ERROR ' .. label .. ': ' .. tostring(result)); errors[label] = true end + return fallback +end + +local function point(p) return {x=p.x, y=p.y} end +local function distance(a,b) return math.sqrt((a.x-b.x)^2+(a.y-b.y)^2) end + +local function drone_snapshot(drone, slot, is_space) + local id=tostring(drone.selfId) + if drone.selfId<0 then + id=(is_space and 'space-' or 'internal-')..tostring(drone.iShipId)..'-'..tostring(slot) + end + local result={id=id,native_id=drone.selfId,slot=is_space and -1 or slot,name=drone.blueprint.name, + kind=drone.blueprint.typeName,type=drone.type,owner=drone.iShipId, + powered=drone.powered,deployed=drone.deployed,dead=drone.bDead,is_space=is_space} + if is_space then + result.space=drone.currentSpace + result.x, result.y=drone.currentLocation.x,drone.currentLocation.y + result.angle=drone.current_angle + result.firing=drone.bFire + result.target_ship=drone.destinationSpace + result.target=point(drone.targetLocation) + else + local location=drone:GetWorldLocation() + result.space=drone.iShipId + result.x,result.y=location.x,location.y + end + return result +end + +local function append_space_drone(list, drone, slot) + local value=drone_snapshot(drone,slot,true) + -- Ordinary drones belong to ShipManager; special drones can also occur + -- in SpaceManager. Retain one record when both vectors contain an object. + for _,existing in ipairs(list) do + if existing.is_space and existing.owner==value.owner and + ((value.native_id>=0 and existing.native_id==value.native_id) or + (value.native_id<0 and existing.native_id<0 and existing.name==value.name and + existing.space==value.space and existing.x==value.x and existing.y==value.y)) then return end + end + list[#list+1]=value +end + +local function impact(projectile, outcome, ship, location) + next_event=next_event+1 + events[#events+1]={id='impact-'..tostring(next_event), projectile_id=tostring(projectile.selfId), + phase='impact', outcome=outcome, ship=ship, target=location and point(location) or nil} +end + +local function ship_snapshot(ship, gui) + if not ship then return nil end + 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={}, + systems={}, system_power={}, system_status={}, room_systems={}} + for id=0,15 do + local name=system_names[id] + if ship:HasSystem(id) then + local system=ship:GetSystem(id) + result.systems[name]=true + result.system_power[name]=ship:GetSystemPower(id) + local row={id=id,key=name,name=name,room_id=system.roomId, + allocated=system.powerState.first,max_power=system.powerState.second, + effective_power=system:GetEffectivePower(),health=system.healthState.first, + max_health=system.healthState.second, + damaged=system.healthState.second-system.healthState.first, + ionized=system.iLockCount>0,locked=system:GetLocked(),hacked=system.iHackEffect, + powerable=system:GetNeedsPower(),power_cap=system:GetPowerCap(), + battery_power=system.iBatteryPower,bonus_power=system.iBonusPower, + on_fire=system.bOnFire,breached=system.bBreached,icon=name} + result.system_status[#result.system_status+1]=row + if id==8 then result.door_level=system.powerState.second end + end + end + local reactor=Hyperspace.PowerManager.GetPowerManager(ship.iShipId) + result.reactor={available=reactor:GetAvailablePower(),total=reactor:GetMaxPower(), + used=reactor.currentPower.first,installed=reactor.currentPower.second, + battery_available=reactor.batteryPower.second-reactor.batteryPower.first, + battery_total=reactor.batteryPower.second} + local breaches=ship.ship:GetHullBreaches(true) + local shield = ship:GetShieldPower() + result.shield = shield.first + result.super_shield = shield.super.first + local ellipse=ship.ship:GetBaseEllipse() + result.shield_shape={center=point(ellipse.center),a=ellipse.a,b=ellipse.b} + local image=ship.ship.shipImage + result.ship_image={x=image.x,y=image.y,w=image.w,h=image.h} + result.ship_image.name=ship.ship.shipImageName + result.ship_image.resource=image.resId + if image.tex then + result.ship_image.texture_w=image.tex.width + result.ship_image.texture_h=image.tex.height + end + for i=0, graph.rooms:size()-1 do + local shape = graph:GetRoomShape(i) + local center = ship:GetRoomCenter(i) + local system=ship:GetSystemInRoom(i) + if system then result.room_systems[tostring(i)]=system_names[system.iSystemType] or 'system' end + local fire_tiles,breach_tiles={},{} + if ship:GetFireCount(i)>0 then + for x=shape.x+17.5,shape.x+shape.w-1,35 do + for y=shape.y+17.5,shape.y+shape.h-1,35 do + local fire=ship:GetFireAtPoint(x,y) + if fire.fDamage>0 then fire_tiles[#fire_tiles+1]={x=x,y=y,damage=fire.fDamage} end + end + end + end + for j=0,breaches:size()-1 do + local breach=breaches[j] + -- Hull breaches store the tile's corner; consumers use tile centers. + if breach.roomId==i then breach_tiles[#breach_tiles+1]={x=breach.pLoc.x+17.5,y=breach.pLoc.y+17.5,damage=breach.fDamage} end + end + result.rooms[#result.rooms+1] = {id=i, x=shape.x, y=shape.y, w=shape.w, h=shape.h, + center=point(center), visible=not graph:GetRoomBlackedOut(i), oxygen=graph:GetRoomOxygen(i), + fires=ship:GetFireCount(i),fire_tiles=fire_tiles,breach_tiles=breach_tiles,breaches=#breach_tiles} + end + local seen_doors={} + for list_index,list in ipairs({ship.ship.vDoorList,ship.ship.vOuterAirlocks}) do + for i=0,list:size()-1 do + local door=list[i] + local key=tostring(door.x)..':'..tostring(door.y)..':'..tostring(door.bVertical) + if not seen_doors[key] then + seen_doors[key]=true + -- Vanilla gives every exterior airlock native id -1. Keep a + -- unique semantic handle, retaining the original id for logs. + local id=list_index==2 and 10000+i or door.iDoorId>=0 and door.iDoorId or i + local locked=door.lockedDown.running + local forced=door.forcedOpen.running + result.doors[#result.doors+1]={id=id,native_id=door.iDoorId,x=door.x,y=door.y, + vertical=door.bVertical,open=door.bOpen or door.bFakeOpen, + room_a=door.iRoom1,room_b=door.iRoom2,health=door.health,max_health=door.baseHealth, + locked=locked,forced_open=forced,hacked=door.iHacked,ionized=door.bIoned, + level=door.doorLevel,blast=door.iBlast, + controllable=ship.iShipId==0 and ship:DoorsFunction() and not locked and not forced} + end + end + end + for i=0, ship.vCrewList:size()-1 do + local crew = ship.vCrewList[i] + if not crew:IsDead() and not crew:OutOfGame() then + local id=tostring(crew.extend.selfId) + local previous=crew_positions[id] + local running=previous~=nil and previous.ship==crew.currentShipId and + (previous.tick==tick and previous.running or + ((crew.x-previous.x)^2+(crew.y-previous.y)^2)>0.04) + crew_positions[id]={x=crew.x,y=crew.y,ship=crew.currentShipId,tick=tick,running=running} + result.crew[#result.crew+1] = {id=id, index=i, + name=crew.blueprint:GetNameShort(), species=crew.species, x=crew.x, y=crew.y, + room=crew.iRoomId, owner=crew.iShipId, ship=crew.currentShipId, + controllable=crew:GetControllable(), selected=crew.selectionState>0, + health=crew.health.first, health_max=crew.health.second, + fighting=crew.bFighting, repairing=crew:Repairing(), + running=running,working=crew.bActiveManning,is_drone=crew:IsDrone()} + end + end + local drones=ship:GetDroneList() + for i=0,drones:size()-1 do + local drone=drones[i] + -- Hyperspace casts Drone* to its real subclass. Ordinary flying + -- equipment drones must be included here as well as special vectors. + if drone.type==2 or drone.type==3 then + result.drones[#result.drones+1]=drone_snapshot(drone,i,false) + else + append_space_drone(result.drones,drone,i) + end + end + for i=0,ship.spaceDrones:size()-1 do + 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 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, + kind=kind, powered=weapon.powered, autofire=weapon.autoFiring, charge=weapon.cooldown.first, + cooldown=weapon.cooldown.second, mount=point(weapon.mount.position), + 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, + num_shots=weapon.numShots,shots_fired=weapon.shotsFiredAtTarget,queued_shots=weapon.queuedProjectiles:size(), + sub_charge=weapon.subCooldown.first,sub_cooldown=weapon.subCooldown.second, + muzzle=point(weapon.weaponVisual.fireLocation), firing=weapon.weaponVisual.bFiring} + end + return result +end + +local function snapshot() + local app = Hyperspace.App + local gui = app.gui + local player, enemy = Hyperspace.ships.player, Hyperspace.ships.enemy + local state = {protocol=2, sequence=sequence, source='hyperspace', ready=app.world.bStartedGame, + combat=enemy~=nil and not enemy.bDestroyed, paused=gui.bPaused, + frozen=app.world.space.gamePaused, ui_mode='game', tactical=tactical, shots=events, projectiles={}} + if app.menu.bOpen or not state.ready then state.ui_mode='menu' end + state.ship_builder_open=app.menu.shipBuilder.bOpen + state.event_pending = gui.event_pause and not gui.choiceBoxOpen + state.transition = state.event_pending or (player and player.bJumping or false) + if state.ui_mode~='menu' and gui.menu_pause and not gui.choiceBoxOpen and not state.transition then state.ui_mode='screen' end + state.event_open = gui.choiceBoxOpen + if tactical and state.ui_mode~='menu' then state.ui_mode='screen' end + if state.event_open then + state.dialog = attempt('dialog', function() + local box=gui.choiceBox + local choices={} + local signature=box.mainText + for i=0,box.choices:size()-1 do + local choice=box.choices[i] + signature=signature .. '|' .. tostring(choice.type) .. ':' .. choice.text + choices[#choices+1]={index=i,text=choice.text,type=choice.type,enabled=choice.type~=1} + end + if signature~=dialog_signature then dialog_id=dialog_id+1; dialog_signature=signature end + return {id=tostring(dialog_id),text=box.mainText,choices=choices,centered=box.centered} + end, nil) + else + dialog_signature='' + end + local space = app.world.space + state.hazard = space.bStorm and 'storm' or space.bNebula and 'nebula' or + space.sunLevel and 'sun' or space.pulsarLevel and 'pulsar' or + space.asteroidGenerator.bRunning and 'asteroid' or 'clear' + if player and state.ready then + state.player = ship_snapshot(player, gui) + state.hull, state.hull_max = state.player.hull, state.player.hull_max + state.shield, state.super_shield = state.player.shield, state.player.super_shield + state.fuel, state.scrap = player.fuel_count, player.currentScrap + state.oxygen = player:GetOxygenPercentage() + state.player_origin = point(gui.shipPosition) + state.enemy_origin = {x=gui.combatControl.position.x+gui.combatControl.targetPosition.x, + y=gui.combatControl.position.y+gui.combatControl.targetPosition.y} + state.weapon_selected = gui.combatControl.weapControl.armedSlot + -- Read the systems' armed state directly. Cursor icons can be stale when + -- their normal render is hidden by the VR presentation. + local mind_mode=player.mindSystem and player.mindSystem.iArmed or 0 + local teleport_mode=player.teleportSystem and player.teleportSystem.iArmed or 0 + local hacking_armed=player.hackingSystem and player.hackingSystem.bArmed or false + local target_kind=mind_mode~=0 and 'mind' or teleport_mode~=0 and 'teleporter' or + hacking_armed and 'hacking' or (state.weapon_selected>=0 or gui.combatControl.weapControl.armedWeapon~=nil) and 'weapon' or '' + state.targeting={active=target_kind~='',kind=target_kind, + ships=target_kind=='mind' and {'player','enemy'} or {'enemy'}, + mode=target_kind=='teleporter' and teleport_mode or target_kind=='mind' and mind_mode or 0} + state.autofire = gui.combatControl.weapControl.autoFiring + state.drone_slots = player:GetDroneList():size() + state.beam_targeting = gui.combatControl.movingBeam + state.boss_visual = gui.combatControl.boss_visual + state.jump_button = point(gui.ftlButton.position) + state.ship_button = point(gui.upgradeButton.position) + state.map_open = app.world.starMap.bOpen + if state.ui_mode~='menu' and (state.map_open or gui.equipScreen.bOpen) then state.ui_mode='screen' end + local loc = app.world.starMap.currentLoc + local at_store = loc and loc.event and loc.event.store or false + state.navigation = { + jump=gui.ftlButton.bActive and player.jump_timer.first>=player.jump_timer.second and + player:GetSystemPower(1)>0 and player:GetSystemPower(6)>0 and not player.bJumping, + ship=gui.upgradeButton.bActive, + store=at_store and gui.upgradeButton.bActive and not gui.dangerLocation} + end + if state.combat then + state.enemy = ship_snapshot(enemy, gui) + state.enemy_hull, state.enemy_hull_max = state.enemy.hull, state.enemy.hull_max + state.enemy_shield, state.enemy_super_shield = state.enemy.shield, state.enemy.super_shield + end + -- All in-game native windows float above the table. The actual main menu + -- keeps its existing headset screen, and navigation remains on the hand. + local tab_open=tick-panel_render_tick<=8 + if state.ui_mode~='menu' and tab_open and not state.transition then state.ui_mode='screen' end + state.blocking_ui=state.ui_mode=='menu' or state.map_open or state.event_open or state.event_pending or + state.transition or gui.menu_pause or gui.equipScreen.bOpen or tab_open + state.panel_open=state.ready and (gui.menu_pause or gui.equipScreen.bOpen or tab_open) and not + (state.map_open or state.event_open or state.event_pending or state.transition) + state.panel_kind=state.panel_open and (tab_open and panel_tab or gui.equipScreen.bOpen and 'ship' or 'window') or nil + for i=0,space.drones:size()-1 do + local drone=space.drones[i] + local side=drone.iShipId==0 and state.player or state.enemy + if side then append_space_drone(side.drones,drone,i) end + end + for id,position in pairs(crew_positions) do + if tick-position.tick>120 then crew_positions[id]=nil end + end + local alive={} + for i=0, space.projectiles:size()-1 do + local p=space.projectiles[i] + if not projectile_ids[p.selfId] and not p:Dead() and (p:GetType()==2 or p:GetType()==6) then + local target_ship=Hyperspace.ships(p.targetId) + if target_ship then + next_id=next_id+1 + projectile_ids[p.selfId]={id=next_id,start=point(p.position)} + local graph=Hyperspace.ShipGraph.GetShipInfo(p.targetId) + events[#events+1]={id='fire-'..tostring(next_id),projectile_id=tostring(p.selfId), + source='environment',ship=p.targetId,phase='fire',outcome='pending', + target_space=p.targetId, + kind=p:GetType()==2 and 'asteroid' or 'laser',origin_point=point(p.position), + target=point(p.target),target_room=graph:GetSelectedRoom(p.target.x,p.target.y,true)} + end + end + local record=projectile_ids[p.selfId] + if record and not p:Dead() then + 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)) + end + local live={id=tostring(p.selfId), progress=progress, missed=p.missed} + if record.beam and p.currentSpace==p.targetId then + live.beam_point=point(record.beam.sub_end) + end + state.projectiles[#state.projectiles+1]=live + end + end + for id in pairs(projectile_ids) do + if not alive[id] then projectile_ids[id]=nil end + end + render_ui_only = state.ready and not Hyperspace.App.menu.bOpen and not tactical + sequence = sequence + 1 + log('FTLVR_STATE ' .. json(state)) + events = {} +end + +script.on_internal_event(Defines.InternalEvents.ON_TICK, function() + -- A failed supplemental capture cannot keep the real game render hidden. + supplemental_hud=false + tick = tick + 1 + if tick % 6 == 0 then attempt('snapshot', snapshot, nil) end +end) + +-- Hyperspace supplies the native tab name through this render callback. +if Defines.RenderEvents.TABBED_WINDOW then + script.on_render_event(Defines.RenderEvents.TABBED_WINDOW,function(name) + panel_tab=tostring(name) + panel_render_tick=tick + return Defines.Chain.CONTINUE + end,function() end) +end + +script.on_internal_event(Defines.InternalEvents.PROJECTILE_FIRE, function(projectile, weapon) + next_id = next_id + 1 + local id = next_id + projectile_ids[projectile.selfId] = {id=id,start=point(projectile.position)} + local target_ship = Hyperspace.ships(projectile.targetId) + if not target_ship or projectile.ownerId < 0 or projectile.ownerId > 1 then return end + local kind = weapon.blueprint.typeName:lower() + if kind == 'burst' then kind='laser' end + if kind == 'missiles' then kind='missile' end + if projectile.damage.iIonDamage > 0 then kind='ion' end + local graph = Hyperspace.ShipGraph.GetShipInfo(projectile.targetId) + local slot=0 + 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 + end + local event = {id='fire-'..tostring(id), projectile_id=tostring(projectile.selfId), weapon_slot=slot, + source=projectile.ownerId==0 and 'player' or 'enemy', kind=kind, + target_space=projectile.targetId, + start=point(projectile.position), target=point(projectile.target), + target_room=graph:GetSelectedRoom(projectile.target.x, projectile.target.y, true), + phase='fire', duration=0.7, outcome='pending'} + if kind=='beam' then + event.end_point=attempt('beam_end', function() return point(projectile.target2) end, point(projectile.target)) + projectile_ids[projectile.selfId].beam=projectile + end + events[#events+1] = event +end) + +script.on_internal_event(Defines.InternalEvents.DRONE_FIRE, function(projectile, drone) + next_id=next_id+1 + projectile_ids[projectile.selfId]={id=next_id,start=point(projectile.position)} + local target_ship=Hyperspace.ships(projectile.targetId) + if target_ship and (projectile.ownerId==0 or projectile.ownerId==1) then + local graph=Hyperspace.ShipGraph.GetShipInfo(projectile.targetId) + local kind=projectile:GetType()==5 and 'beam' or projectile.damage.iIonDamage>0 and 'ion' or 'laser' + local event={id='fire-'..tostring(next_id),projectile_id=tostring(projectile.selfId), + phase='fire',outcome='pending',source=projectile.ownerId==0 and 'player' or 'enemy', + drone_id=tostring(drone.selfId),origin_space=drone.currentSpace, + target_space=projectile.targetId, + kind=kind,origin_point=point(projectile.position),target=point(projectile.target), + target_room=graph:GetSelectedRoom(projectile.target.x,projectile.target.y,true)} + if kind=='beam' then + projectile_ids[projectile.selfId].beam=projectile + event.end_point=point(projectile.target2) + end + events[#events+1]=event + end + return Defines.Chain.CONTINUE +end) + +script.on_internal_event(Defines.InternalEvents.SHIELD_COLLISION, function(ship, projectile, damage, response) + -- Actual collision responses are retained; the renderer never decides damage. + if projectile and response.collision_type==2 then + impact(projectile,'shield',ship.iShipId,response.point) + end + return Defines.Chain.CONTINUE +end) +script.on_internal_event(Defines.InternalEvents.DAMAGE_BEAM, function(ship, projectile, location, damage, newTile, beamHit) + if projectile and newTile then impact(projectile,'beam',ship.iShipId,location) end + return Defines.Chain.CONTINUE, beamHit +end) +script.on_internal_event(Defines.InternalEvents.DAMAGE_AREA_HIT, function(ship, projectile, location, damage, friendly) + if projectile then + impact(projectile,'hull',ship.iShipId,location) + end + return Defines.Chain.CONTINUE +end) + +script.on_internal_event(Defines.InternalEvents.ON_KEY_DOWN, function(key) + if key==Defines.SDL.KEY_F6 then supplemental_hud=true; return Defines.Chain.PREEMPT end + if key==Defines.SDL.KEY_F7 then supplemental_hud=false; return Defines.Chain.PREEMPT end + if key==Defines.SDL.KEY_F9 then + Hyperspace.App:OnRequestExit() + return Defines.Chain.PREEMPT + end + if key==Defines.SDL.KEY_F8 then tactical=not tactical; return Defines.Chain.PREEMPT end + return Defines.Chain.CONTINUE +end) + +-- Render callbacks also advance FTL jump animations. Keep those callbacks +-- running and clip their geometry offscreen; never skip game logic to hide it. +for _, name in ipairs({'LAYER_BACKGROUND','LAYER_FOREGROUND','LAYER_ASTEROIDS','LAYER_PLAYER','SHIP','SHIP_MANAGER','SHIP_JUMP','LAYER_FRONT'}) do + if Defines.RenderEvents[name] then + local active={} + script.on_render_event(Defines.RenderEvents[name], function() + if supplemental_hud then + active[#active+1]=false + -- This independent HUD pass must not update world render + -- animations a second time. Normal render callbacks still run. + return Defines.Chain.PREEMPT + end + -- Read the native state each render, including jump/event frames; + -- snapshots are intentionally slower than the renderer. + render_ui_only = Hyperspace.App.world.bStartedGame and not Hyperspace.App.menu.bOpen and not tactical + active[#active+1]=render_ui_only + if render_ui_only then + Graphics.CSurface.GL_PushMatrix() + -- agent.js hooks this sentinel and pushes an empty scissor. + -- The after callback must pop both the matrix and that scissor. + Graphics.CSurface.GL_Translate(-100000,-100000,0) + end + return Defines.Chain.CONTINUE + end, function() + if active[#active] then + Graphics.CSurface.GL_PopMatrix() + Graphics.CSurface.GL_PopScissor() + end + active[#active]=nil + end) + end +end +log('FTLVR_BRIDGE_READY protocol=2') diff --git a/config/launcher.example.json b/config/launcher.example.json new file mode 100644 index 0000000..1f46303 --- /dev/null +++ b/config/launcher.example.json @@ -0,0 +1,5 @@ +{ + "lab": "C:/Games/FTL-VR-Lab", + "hooks": "C:/Games/FTL-VR-Lab/hooks.json", + "godot": "C:/Tools/Godot/Godot_v4.7.2-stable_win64.exe" +} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..389b14f --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,73 @@ +# Architecture + +[Back to README](../README.md) · [Development](DEVELOPMENT.md) · [Project status](STATUS.md) + +FTL Tabletop VR is a companion renderer and input bridge for the **real single-player FTL process**. It does not reimplement the campaign, combat simulation or save format. FTL remains responsible for damage, cooldowns, crew pathfinding, events, progression, audio and saves. + +## Data and input flow + +```mermaid +flowchart LR + FTL[Isolated FTL + Hyperspace] -->|Lua snapshots and native render capture| Bridge[Python / Frida bridge] + Bridge -->|State JSON and raw RGBA frames| Client[Godot OpenXR client] + Client -->|Scoped command queue| Bridge + Bridge -->|Native input handlers| FTL + Owned[Player's owned ftl.dat] --> Extract[Local asset extractor] + Extract -->|Ignored local assets| Client +``` + +The normal launcher coordinates both programs. Commands call the verified game process's input handlers rather than generating global desktop mouse or keyboard events. + +## Main components + +| Location | Responsibility | +|---|---| +| [project.godot](../project.godot), [main.tscn](../main.tscn) | Godot entry point, renderer and OpenXR configuration | +| [scripts/main.gd](../scripts/main.gd) | Scene lifecycle, placement, state application, controller input and context-sensitive interaction | +| [openxr_action_map.tres](../openxr_action_map.tres) | Steam Frame and fallback controller action profiles | +| [bridge/ftlvr_bridge.lua](../bridge/ftlvr_bridge.lua) | Hyperspace snapshots of native ships, rooms, crew, systems, doors, drones, hazards and combat events | +| [bridge/agent.js](../bridge/agent.js) | Frida native hooks, capture and queued process-scoped input | +| [tools/run_bridge.py](../tools/run_bridge.py) | Process bridge, snapshot normalization, input translation, asset lookup and frame publication | +| [tools/launch.py](../tools/launch.py) | Preflight, VR-save backup, game/client launch, freshness checks and graceful shutdown | +| [tools/prepare_lab.py](../tools/prepare_lab.py), [tools/resolve_hooks.py](../tools/resolve_hooks.py) | Isolated game preparation and exact supported executable hook resolution | +| [tools/extract_ftl.py](../tools/extract_ftl.py) | Owner-local layouts, hull images, UI icons/artwork and bitmap fonts | +| [scripts/ship_model.gd](../scripts/ship_model.gd), `voxel_*.gd` | Volumetric hull presentation, doors, crew, weapons and drones | +| [scripts/combat_effects.gd](../scripts/combat_effects.gd), [scripts/space_environment.gd](../scripts/space_environment.gd) | Native-driven shot presentation and surrounding space/hazards | +| `scripts/hud_*.gd`, `controller_*.gd`, `ftl_ui_*.gd` | Original captured HUD, themed hand screens, Help infographic and local font decoding | +| [scripts/shortcut_wheel.gd](../scripts/shortcut_wheel.gd), [scripts/rename_keyboard.gd](../scripts/rename_keyboard.gd) | Contextual shortcuts and native rename field interaction | + +## Authoritative state + +The Lua snapshot protocol identifies its source as Hyperspace and includes ships, rooms, crew, doors, weapons, drones, system power/status, current UI context, dialog choices and combat events. The Python layer normalizes optional collections and crew membership, then publishes fresh state atomically. + +Godot uses this state to place and animate visual actors. Native positions, current ship/space, ownership, deployment, cooldowns and sensor visibility matter independently. For example, an enemy-owned combat drone attacking the player renders around the player ship. Boarding crew render on the ship they currently occupy. + +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. + +## Original HUD and floating windows + +The game renders its original HUD into a separate native framebuffer. This retains native fonts, localization, power widgets, tooltips and values even when the normal backbuffer displays Tactical, the map or a window. The bridge removes unwanted world/enemy HUD pixels for the headset layer and preserves complete native window content for floating panels. + +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. + +The client reuses textures, reads only fresh frames and renders UI subviewports on changes. HUD/panel crops and alpha-aware pointing preserve native control coordinates. The hand panel receives priority over the headset HUD where they overlap. Rooms use floor intersections for targeting and crew drops, avoiding doors or miniatures blocking the intended destination. + +## Local assets and generated data + +`local_game_data/` is intentionally absent from this source package and ignored by Git. Each player extracts required content from their owned `ftl.dat`. The bridge can produce layout/image variants for encountered ships from that same local archive. The original soundtrack plays through FTL itself. + +Hull artwork, system icons, fonts and captured game pixels are owner-local. Crew, weapon and drone geometry is created by the project's procedural model code. Shared immutable meshes/materials, cached icons/shields and batched room-hazard transforms reduce scene overhead. + +During a live session local files include state, frame streams, the command queue, bridge status, tracking diagnostics and logs. These are transient operational files; do not commit them or include a live local-data folder in a source release. + +## Isolation and version gates + +The verified route prepares a separate copy of Steam Windows FTL 1.6.14, applies the official rollback to 1.6.9, and activates Hyperspace there. Native hooks are resolved for that exact supported executable. Unknown or changed executable fingerprints are rejected; removing these checks is not a compatibility fix. + +The lab uses a separate `hs_ftlvr_` save prefix, and the launcher backs up that VR profile and lab settings before running. Game input is scoped to the verified process; text operations include an active-field identity so stale keyboard commands are rejected. Closing the client requests FTL's normal save-and-exit route. + +## Demo versus live play + +`--demo` avoids a live campaign connection and enables mocked renderer actions. Full ship/UI visuals still require owner-local extracted assets. Running the Godot project alone does not launch FTL and cannot provide an actual campaign. Use the combined launcher for live desktop or VR play after completing [installation](INSTALLATION.md). + +Rendered fixtures, tests and desktop profiling establish specific renderer behavior. They do not establish Steam Frame comfort, complete campaign support or stereo headset frame pacing. diff --git a/docs/CONTROLS.md b/docs/CONTROLS.md new file mode 100644 index 0000000..8686b79 --- /dev/null +++ b/docs/CONTROLS.md @@ -0,0 +1,118 @@ +# Controls + +[Back to README](../README.md) · [Installation](INSTALLATION.md) · [Project status](STATUS.md) + +The main mapping targets **Steam Frame controllers through SteamVR/OpenXR**. Other OpenXR profiles are included, but their comfort and complete button coverage have not been verified on hardware. The left hand holds navigation; the right hand points and acts. + +Open **Controls / Help** on the left panel for an illustrated controller diagram. The HUD and hand panel also display contextual input hints. The diagram is drawn by [controller_help.gd](../scripts/controller_help.gd); the actual action bindings are in [openxr_action_map.tres](../openxr_action_map.tres). + +## Steam Frame quick reference + +| Control | Action | +|---|---| +| 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 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 | +| Right stick click, held | Hold the game's Ctrl modifier | +| 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 | +| Left Dpad Up | Open or close Shortcuts | +| Left Dpad Left / Right | Previous / next hand-screen page | +| Left grip, held + hand motion | Move the encounter | +| Left stick horizontal / vertical | Rotate / resize the encounter | +| Left stick click | Recenter the encounter | +| Left trigger | Return crew to stations, except on System Power | +| Right grip + left trigger | Save stations, except on System Power | + +The page order is **Navigation → Tactical → Shortcuts → System Power → Jump → Help**. 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. + +## System Power page + +This page changes the contextual controls below. **Steam Frame X/Y are on the right controller.** + +| Control | Action | +|---|---| +| Point at a system card | Select that system without changing its power | +| Right X / Y | Select previous / next installed powerable system | +| Left trigger | Add one native power step to the selected system | +| Left bumper | Remove one native power step | + +The game decides whether a change is allowed. One engine step changes one power bar; one shield step normally changes two. Damage, ion locks, capacity, bonus power and battery power follow the actual game state. The reactor column shows available power. A stationary pointer does not continuously override X/Y selection. + +## Crew, doors and room targeting + +- Trigger-click a crew miniature or original HUD portrait to select it. Hold right grip while selecting to add to the selection group. +- Hold the trigger on a crew member, aim at the highlighted destination floor, then release. The miniature is a preview; FTL moves the actual crew under its usual rules. +- 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. +- 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. + +## Shortcut wheel and event choices + +Hold the **right bumper**, move the right stick toward a slot and release the bumper to activate it. Returning the stick to the center cancels. Right trigger confirms immediately; B cancels. Right A changes the category. + +The wheel starts with weapon slots 1–4 and drone slots 5–8. Other categories provide system power and system actions. Uninstalled or unavailable entries cannot activate. When a choice event is open, the wheel becomes a choice selector for entries 1–8; original dialog controls remain pointable. + +Shift and Ctrl also apply when confirming relevant wheel or shortcut commands. + +| Original shortcut | VR equivalent | +|---|---| +| Shift + crew selection | Hold right grip while trigger-clicking crew | +| Shift + weapon/drone shortcut | Hold right grip while selecting its shortcut to depower | +| Shift + system power shortcut | Hold right grip while selecting its shortcut to remove power | +| Ctrl + weapon selection/target | Hold right stick click to reverse global autofire for that weapon | +| Right click | Point at the original control and press right B | + +Shortcut pages include crew selection/stations, doors, autofire, installed weapons/drones and installed system actions. The original keyboard labels remain visible: + +- Power: A shields, S engines, F oxygen, D medbay/clonebay, W weapons, E drones, G teleporter, H cloaking, K mind control, L hacking, Y artillery. +- Actions: Q all crew; `/` save stations; Enter return to stations; Z/X open/close doors; V autofire; C cloak; T/R send/return teleporters; N hacking; M mind control; B battery. + +These are native key equivalents shown on buttons, not extra physical controller buttons. + +## Navigation, windows and renaming + +**Jump** opens the actual sector map above and ahead of the installed piloting room. Point at a beacon using the right controller and use the original confirmation controls. The map follows the ship's cockpit and orientation. **Close Map** returns to the encounter. + +Events, Store (including BUY/SELL), upgrades, crew and equipment windows float above the player ship. Their original localized controls stay interactive. Main menus remain 2D. The original HUD follows the headset; the hand panel has visual and pointing priority where it overlaps the HUD. + +To rename a ship or crew member, click its original name-edit field. A floating **QWERTY keyboard** appears: + +- Right trigger selects keys; Shift changes case. +- Back deletes one character; Clear empties the field. +- Done confirms; Cancel or right B restores the original name. +- Native name-length limits apply. Desktop Unicode typing is forwarded while the field is active. + +The VR keyboard does not install a keyboard layout or alter Windows language settings. + +## Placement and tracking + +At first tracked launch the ship is placed in front of and below the headset. The encounter then stays where it was placed until moved with left grip, resized/rotated or recentered. It does not continually follow the headset. Enemy arrival retains the player anchor. + +The controller panel and pointer use bounded pose smoothing. If the right controller loses tracking, a warning appears. Check SteamVR controller status and reconnect before continuing if pointing disappears. + +## Desktop client + +Desktop mode is useful for setup and debugging; it is not a headset performance test. + +| Input | Action | +|---|---| +| Left click | Point/select/order using the native HUD and 3D scene | +| Right click | Native secondary click/cancel | +| Space | Pause/resume | +| 1–4 / 5–8 | Weapon / drone shortcuts | +| E | Inspect enemy rooms | +| R | Recenter | +| J | Open navigation | +| F8 | Toggle Tactical | +| Escape | Native menu | + +Live desktop mode forwards actions to the isolated FTL process. The separate `--demo` mode provides renderer fixtures; it does not run a campaign. diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md new file mode 100644 index 0000000..c51b92c --- /dev/null +++ b/docs/DEVELOPMENT.md @@ -0,0 +1,114 @@ +# Development and verification + +[Back to README](../README.md) · [Architecture](ARCHITECTURE.md) · [Contributing](../CONTRIBUTING.md) · [Project status](STATUS.md) + +Start with [installation](INSTALLATION.md) for the verified Windows tool versions and local asset import. This is a source prototype with a version-specific native bridge, not a universal drop-in game converter. + +## Working on the client + +Open `project.godot` in the documented Godot version. Keep `local_game_data/`, imported caches, exports, lab copies and third-party binaries out of the repository. For game-free renderer inspection, start the project with `--demo --desktop`: + +```powershell +godot --xr-mode off --path . -- --demo --desktop +``` + +This does not launch a native campaign. Detailed ship/UI inspection still needs local extraction from an owned game archive. Live play uses `tools/launch.py`, not the demo command. Do not use stale captured state as evidence that a new native integration works. + +The main implementation areas are listed in [Architecture](ARCHITECTURE.md). A focused change usually fits one of four paths: + +1. **Presentation:** procedural models, effects or placement; preserve native positions/visibility and pause behavior. +2. **Controller/UI:** action mapping, ray/crop picking, contextual pages and panel priority; update the Help diagram and [controls](CONTROLS.md) together. +3. **Bridge:** schema and native command translation; verify native outcomes in a separate lab/profile. +4. **Transport/performance:** capture/read/upload timing; keep resolution-independent native picking and avoid per-frame mesh/texture allocation. + +## Python checks + +After installing the documented Python requirements, run from the repository root: + +```powershell +python -m unittest discover -s tools -p "test_*.py" +``` + +The last development revision passed **27 Python tests**. They exercise synthetic archive/layout/font extraction, bridge schema/input translation, native-pixel filtering and raw frame publication. They do not launch FTL. Temporary fixture files are created by the tests. + +## 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 seven suites below passed in the previous configured development environment. A fresh source checkout lacks game assets; model/input/font checks may fail until local extraction is complete. + +```powershell +godot --headless --xr-mode off --path . --script tools/test_combat.gd -- --demo +godot --headless --xr-mode off --path . --script tools/test_input.gd -- --demo --desktop +godot --headless --xr-mode off --path . --script tools/test_models.gd -- --demo --desktop +godot --headless --xr-mode off --path . --script tools/test_environment.gd -- --demo --desktop +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 +``` + +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. + +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 + +Keep a disposable isolated lab/profile for automation. Back up its saves/settings and record how to restore it before interacting with FTL. Inspect the actual snapshot/input/capture result, not just a success log from the client. Do not perform live experiments in a valuable campaign. + +```powershell +python tools/launch.py --check --desktop +python tools/launch.py --smoke +``` + +`--check` performs preflight without launching. `--smoke` launches the actual isolated game and desktop client, then requests client shutdown after a short interval and checks cleanup. It uses the configured VR save prefix, takes the normal pre-launch backup and may save game state on exit; it is not a purely synthetic test. + +When adding a system or command, compare the visible native outcome and snapshot: installed-system availability, native power step, target room/ship, actual crew position, resource change or event transition. A target being accepted does not establish that its eventual effect completed. Hacking attachment/effects, native beam/bomb encounter coverage and full campaigns remain areas for further verification. + +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 + +For a running disposable live session with Tactical open: + +```powershell +python tools/profile_tactical.py +godot --xr-mode off --path . --script tools/profile_tactical.gd -- --desktop --full-main +``` + +These measure native transport delivery and received textures/desktop rendering. **They are not stereo headset FPS.** The observed improvements and outstanding headset gates are summarized in [STATUS](STATUS.md). Use raw sequence/timestamp freshness rather than the deliberately slow PNG previews. + +Additional helpers: + +| Tool | Use | +|---|---| +| `tools/benchmark_effects.gd` | Renderer effect benchmark; desktop results only | +| `tools/capture_models.gd` | Local model/weapon gallery | +| `tools/capture_ship_details.gd` | Explicit visual fixture for door tiers, fire/breach effects and hull bar | +| `tools/capture_environment.gd` | Environmental presentation inspection | +| `tools/capture_native_models.gd` | Inspect models from an owner-local native snapshot | +| `tools/capture_live.gd`, `tools/record_live.gd` | Capture this client's viewport with a running bridge | +| `tools/capture_input.gd`, `tools/input_harness.gd` | Simulated interaction inspection | + +Fixtures illustrate behavior; label them as fixtures rather than real gameplay. Keep game-derived screenshots, snapshots and extracted resources local. + +## Logs and useful evidence + +- `local_game_data/logs/bridge.log`: native process/bridge errors. +- `local_game_data/logs/client.log`: client output in combined launches. +- `local_game_data/bridge_status.json`: connection, heartbeat and state/capture freshness. +- `local_game_data/xr_input.json`: tracking state, chosen pose, action profile and trigger/grip values. +- `FTLVR_XR_INPUT` client log lines: controller mapping/tracking diagnostics. +- The configured lab's `save-backups/`: pre-launch VR-profile and settings snapshots. + +Include relevant redacted excerpts in bug reports. Remove personal paths, save contents and any unrelated process/account details. A frozen native event/store UI is distinct from the user's pause toggle; check both state fields when diagnosing pause effects. + +## Before proposing a change + +- Describe the behavior changed and the native rule it relies on. +- Run the focused checks for that area; state which assets/build/runtime were used. +- Verify graphical changes in actual rendering and interactions in their relevant UI context. +- For VR changes, report headset/controller testing separately from desktop/simulated results. +- Keep generated data, game binaries, archives, extracted assets and saves out of the diff. +- Update the Help diagram, controls, installation or status documentation when affected. + +Full campaign testing, headset comfort and physical stereo performance remain community testing priorities. Passing all automated suites is useful evidence, not a declaration that these gates are complete. diff --git a/docs/GITHUB_SETUP.md b/docs/GITHUB_SETUP.md new file mode 100644 index 0000000..fd45375 --- /dev/null +++ b/docs/GITHUB_SETUP.md @@ -0,0 +1,40 @@ +# Connect this folder to a new GitHub repository + +This folder is prepared as a standalone local Git repository on the `main` branch. It has no GitHub remote and has not been uploaded. Its history starts with this community source snapshot; the universal-modder development history is not copied into it. + +## GitHub Desktop + +1. Choose **File → Add local repository** and select this folder. +2. Review the files and make any changes you want before publishing. +3. Use **Publish repository**. Suggested name: `ftl-tabletop-vr`. +4. Suggested description: `Experimental OpenXR tabletop VR mod for FTL: Faster Than Light, using your own game installation.` +5. Select your account and visibility, then publish when ready. + +## Git command line + +Create an **empty** repository on GitHub. Leave automatic README, ignore and license creation unchecked because this folder already has those files. In a terminal opened in this folder: + +```powershell +git status +# If you changed or added files, review and commit them first: +git add . +git commit -m "Prepare community release" + +# Replace YOUR-ACCOUNT with your actual account or organization: +git remote add origin https://github.com/YOUR-ACCOUNT/ftl-tabletop-vr.git +git push -u origin main +``` + +If there are no changes to commit, skip that commit command. If a remote named `origin` already exists, inspect it with `git remote -v` before changing it. + +## After publishing + +- Suggested topics: `ftl`, `vr`, `openxr`, `godot`, `steamvr`, `steam-frame`, `modding`. +- Keep the root [MIT license](../LICENSE), [third-party notices](../THIRD_PARTY_NOTICES.md) and [license-reference documents](../licenses/README.md) with the source when copying or redistributing it. +- The issue forms and Windows Python-test workflow are already included. The workflow does not install or run FTL and does not test a VR headset. +- The first public description should call this an **experimental source alpha** and link to [project status](STATUS.md). +- Keep local game data and prepared labs outside commits. `.gitignore` covers the expected generated folders and game formats. +- Do not upload a ZIP of a working game installation as a release. A future client release should contain only the mod/client and generate game-dependent data locally. +- A gameplay demo should be clearly identified as headset or desktop footage. Credit FTL's creators in the video/description, following [Subset's video policy](https://subsetgames.com/faq.html). + +The packaged source does not include local testing captures or the development machine's launch configuration. diff --git a/docs/INSTALLATION.md b/docs/INSTALLATION.md new file mode 100644 index 0000000..fe73e49 --- /dev/null +++ b/docs/INSTALLATION.md @@ -0,0 +1,157 @@ +# Installation + +This is a source release for a technical Windows alpha. Setup currently involves an isolated game copy, a matching Hyperspace installation and native hook resolution. The repository includes convenient environment and launch scripts; a complete installation on a fresh PC has not yet been validated. + +The original FTL process runs gameplay, events, combat, saves and soundtrack. The Godot client reads its live state and forwards VR commands through the bridge. You need your own installed game; no FTL executable, archive, extracted artwork, fonts or music is included here. + +## Prerequisites + +| Requirement | Development/verification baseline | +|---|---| +| Operating system | Windows; the live bridge uses Windows native APIs | +| FTL | Owned **Steam Windows 1.6.14** installation, with an unmodified executable | +| Isolated FTL copy | Prepared by these tools, rolled back to **Windows 1.6.9** | +| Python | **3.10+**, with `venv` and `pip`; development uses Python 3.12 | +| Python packages | `requirements.txt`: Frida 17.9.0, Capstone 5.0.9, Pillow 10+, NumPy 1.24+ | +| Godot | **4.7.2** Windows executable, Vulkan capable GPU/driver | +| Hyperspace | **1.23.2** release archive and matching source/signature files | +| Rollback | Official `patch/Steam-1.6.14.bps` from FTL-Version-Rollback | +| Archive mod manager | **ftlman 0.7.4** Windows executable | +| VR | SteamVR installed/running and selected as the active OpenXR runtime | +| Tested hardware | Steam Frame, RTX 4060 Ti 8 GB, 32 GB RAM; desktop inspection and prior headset playtests | + +The asset extractor can read compatible Steam or GOG 1.6.x archives. The complete GOG executable/loader route has **not** been validated. The preparer intentionally rejects an unknown executable or rollback patch. Other headsets and operating systems have not completed gameplay QA. + +Obtain tools from their official project pages: + +- [Python](https://www.python.org/downloads/windows/) +- [Godot downloads](https://godotengine.org/download/archive/) +- [Hyperspace releases and source](https://github.com/FTL-Hyperspace/FTL-Hyperspace) +- [FTL-Version-Rollback](https://github.com/FTL-Hyperspace/FTL-Version-Rollback) +- [ftlman releases](https://github.com/afishhh/ftlman/releases) + +Keep downloaded tools and the isolated game **outside this repository**. Download the matching Hyperspace source as well as its release archive: the `.zhl` signature files are needed for `resolve_hooks.py`. The project does not download or install these game dependencies automatically. + +## 1. Choose separate folders and back up your saves + +Example paths below are placeholders. Replace them with your own locations: + +| Purpose | Example | +|---|---| +| Repository | `D:\Projects\FTL-Tabletop-VR` | +| Original Steam game | `C:\Program Files (x86)\Steam\steamapps\common\FTL Faster Than Light` | +| New isolated lab | `C:\Games\FTL-VR-Lab` | +| Unpacked Hyperspace release | `C:\Tools\Hyperspace-1.23.2` | +| Hyperspace source | `C:\Tools\FTL-Hyperspace-source` | +| Rollback repository | `C:\Tools\FTL-Version-Rollback` | +| ftlman | `C:\Tools\ftlman\ftlman.exe` | + +Before the first game launch, copy your existing `%USERPROFILE%\Documents\My Games\FasterThanLight` folder to a separate backup. If your Documents folder is redirected, locate the actual FTL saves first. Close FTL before making this copy. The VR profile uses separate `hs_ftlvr_` save files in FTL's save directory; the isolated copy is not a completely separate Windows user profile. + +The lab must be separate from the original game and must not be a parent or child of it. Use a new empty folder. `prepare_lab.py` refuses an unrelated nonempty destination. Do not use a Steam-managed folder for the lab, or point it at your installation. + +## 2. Create the Python environment + +Open a terminal in the repository, then run: + +```powershell +.\SETUP.cmd +``` + +This creates `.venv`, installs `requirements.txt` there and copies the example config to ignored `local_game_data/launcher.json` if that file does not already exist. It requires internet access for Python packages. It does **not** patch FTL, install a loader into the game or launch it. Your Windows language and keyboard settings are not changed. + +You can inspect its actions first with `SETUP.cmd --dry-run`. If the Windows `python` command opens the Microsoft Store, install Python from python.org with the Python launcher, reopen the terminal and retry. + +## 3. Prepare and activate the isolated game + +Check that the unpacked Hyperspace release contains: + +- `Hyperspace.ftl` +- `Windows - Extract these files into where FTLGame.exe is\Hyperspace.dll` +- `Windows - Extract these files into where FTLGame.exe is\xinput1_4.dll` + +Run the following in PowerShell, substituting your paths: + +```powershell +.\.venv\Scripts\python.exe tools\prepare_lab.py ` + --game "C:\Program Files (x86)\Steam\steamapps\common\FTL Faster Than Light" ` + --lab "C:\Games\FTL-VR-Lab" ` + --rollback "C:\Tools\FTL-Version-Rollback\patch\Steam-1.6.14.bps" ` + --hyperspace "C:\Tools\Hyperspace-1.23.2" ` + --mod-manager "C:\Tools\ftlman\ftlman.exe" ` + --activate +``` + +`--activate` explicitly installs the loader and patched resources into **that isolated lab**. The preparer verifies the original executable, rollback patch and resulting 1.6.9 executable. It packages our Lua bridge with Hyperspace resources, creates the `ftlvr` save prefix and sets the lab's window/bindings. The original Steam installation is read as the copy source. + +Omit `--activate` to prepare resources without installing the loader into the lab. A prepared-only lab cannot run this client; rerun with `--activate` when ready. Keep the original installation in its recorded location: the bridge reads its archive to construct newly encountered ship variants. + +## 4. Resolve the local executable hooks + +Use `CApp.zhl` from the matching **win32/1.6.9** directory of the Hyperspace source: + +```powershell +.\.venv\Scripts\python.exe tools\resolve_hooks.py ` + "C:\Games\FTL-VR-Lab\FTLGame.exe" ` + "C:\Tools\FTL-Hyperspace-source\libzhlgen\test\functions\win32\1.6.9\CApp.zhl" ` + "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`, `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. + +## 5. Extract local presentation assets + +Run against your **original owned archive**, not the lab's patched archive: + +```powershell +.\.venv\Scripts\python.exe tools\extract_ftl.py ` + "C:\Program Files (x86)\Steam\steamapps\common\FTL Faster Than Light\ftl.dat" +``` + +This generates the initial player/enemy ship data, original UI artwork and bitmap fonts in ignored `local_game_data/`. The bridge extracts additional ships on demand. Do not commit that folder or the generated lab to a public repository. + +## 6. Configure and check + +Edit `local_game_data/launcher.json`. It contains exactly these local paths: + +```json +{ + "lab": "C:/Games/FTL-VR-Lab", + "hooks": "C:/Games/FTL-VR-Lab/hooks.json", + "godot": "C:/Tools/Godot/Godot_v4.7.2-stable_win64.exe" +} +``` + +Use absolute paths and forward slashes, or double each backslash in JSON. Point `godot` at the actual downloaded executable, including its filename. Keep this configuration local. + +For VR, start SteamVR, connect the headset/controllers and select SteamVR as the current OpenXR runtime in SteamVR settings. Then run: + +```powershell +.\CHECK-SETUP.cmd +``` + +For a desktop check without a VR runtime: + +```powershell +.\CHECK-SETUP.cmd --desktop +``` + +Checks verify dependencies, local extraction, the recorded original archive, the lab marker/fingerprint, required hooks, loader and OpenXR runtime. They do not launch, patch or attach to FTL. A passed check does not establish full campaign compatibility or headset performance. + +## 7. Launch and close + +```powershell +.\RUN-VR.cmd +# Or inspect the live client without a headset: +.\RUN-DESKTOP.cmd +``` + +The launcher backs up the separate VR save files and lab settings, starts the isolated FTL bridge, waits for fresh live state/capture and starts Godot. It can take up to roughly one minute to establish the bridge; failure details appear in the local logs. + +Closing the client requests a graceful native save/exit and stops the bridge. Pre-launch VR backups are in the lab's `save-backups/` folder. Allow shutdown to finish before starting another session. Logs are in `local_game_data/logs/`. See [troubleshooting](TROUBLESHOOTING.md) and [controls](CONTROLS.md). + +Do not change the lab's 1280×720 canvas or reserved bindings while testing. Run the source client with the launcher; opening Godot alone does not create the game bridge. Avoid running automated native QA tools against a campaign you want to keep: those tools can send game commands and change saves. + +## Remove the prototype + +Close the VR client, bridge and isolated game first. The repository, `.venv`, generated `local_game_data/`, downloaded dependencies and separate lab can then be removed as ordinary folders. VR profile files live in the FTL save directory, not only the lab; back them up before removing any `hs_ftlvr_` files. Keep the original game and ordinary FTL saves. diff --git a/docs/STATUS.md b/docs/STATUS.md new file mode 100644 index 0000000..ddb6cc0 --- /dev/null +++ b/docs/STATUS.md @@ -0,0 +1,73 @@ +# Project status + +**Prepared source snapshot:** `0.1.0-alpha.1`, 2026-10-03. + +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. + +## Implemented + +| Area | Current behavior | +| --- | --- | +| 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 | Original native HUD follows the headset; native enemy HUD region is removed in gameplay; hand screen and pointer draw above it | +| Navigation | Available actions only; actual sector map appears above/ahead of the pilot room and faces the viewer | +| Menus/dialogs | Main menus remain 2D; native shop/ship windows and 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, plus native mind-control, teleporter and hacking target routing | +| Systems | Installed-system shortcuts, modifier support and a System Power page using native allocation rules | +| 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 and enemy hull bar using locally imported vanilla art | +| Drones | Distinct procedural space/interior families with deployment/power state and placement by native render space | +| Hazards | Room fire/breach tiles and surrounding asteroid, sun, nebula/storm and pulsar presentations | +| Input | Steam Frame actions, generic fallback profiles, controller infographic, action wheel 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 + +| Check | Evidence and limits | +| --- | --- | +| Real-game startup | Main menu, hangar, new game, event choices and combined client/bridge startup checked | +| Crew and doors | Native crew selection, room movement, station return, modifiers and upgraded door state checked | +| Navigation | Actual map opening, beacon selection, fuel consumption and new event checked | +| Weapons/combat | Player laser/missile targeting, enemy fire, shield/hull impact and native hull damage checked | +| Systems | Power operations for installed powerable systems, native mind control and teleportation effects checked | +| 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 | Latest source revision passed 27 Python tests and seven Godot suites before this packaging task | +| Desktop graphics | Vulkan rendering and model/UI inspection checked on RTX 4060 Ti 8 GB | +| Save handling | Normal combined launch/exit and preservation of backed-up campaign data checked | +| 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. + +## Performance: what the measurements mean + +- The old Tactical texture path delivered about 6.30 updates/second in its measured consumer case. +- The raw RGBA path reached 29.09 in the same consumer case and about 27.89–28.60 in complete desktop scene checks, including an asteroid scene. +- Tactical uses 960×540 capture with a 30 Hz target. Jump/maps and native windows keep 1280×720. The independent native HUD targets 10 Hz. +- These are capture/texture-delivery measurements. They are **not stereo headset FPS, motion-to-photon latency or SteamVR frame-time measurements**. + +## Known limitations and remaining work + +### Before a wider player release + +1. Test setup from this standalone repository on a clean Windows machine. +2. Measure physical Steam Frame readability, hand-screen steadiness, pointer latency and stereo frame pacing after the latest changes. +3. Complete campaign coverage: Flagship phases, ship layouts/races, boarding, cloaking, beams/bombs, drones and hazard combinations. +4. Verify hacking-drone arrival, attachment and resulting effect end to end. +5. Check long-session transition/capture recovery and graceful shutdown under failure conditions. + +### Compatibility and polish + +- The preparer accepts only the fingerprinted Steam Windows 1.6.14 input and official rollback to 1.6.9. Unknown executables are rejected deliberately. +- GOG PKG extraction works; the complete GOG executable/loader route is not verified. +- Other headsets/controller profiles, Linux/macOS and FTL overhaul combinations are not verified. +- Native game rules remain authoritative. VR hazard animation and some projectile/drone presentations are approximations, rather than complete reproductions of their 2D appearance or timing. +- A source setup is supplied; compiled client distribution, export packaging and an end-user installer are not yet validated. +- Python uses pinned Frida/Capstone and minimum Pillow/NumPy versions. Other combinations need testing. + +## Useful next tests + +Start with the supported setup. Report the exact game/loader/client versions, controller interaction profile, whether the problem occurs in VR or desktop, and steps to reproduce. For performance, distinguish stale Tactical textures from low headset frame rate. Use the [issue forms](../.github/ISSUE_TEMPLATE/) and follow [CONTRIBUTING.md](../CONTRIBUTING.md). diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md new file mode 100644 index 0000000..e5ffbf8 --- /dev/null +++ b/docs/TROUBLESHOOTING.md @@ -0,0 +1,59 @@ +# Troubleshooting + +Start with `CHECK-SETUP.cmd`, or `CHECK-SETUP.cmd --desktop` if testing without SteamVR. These checks are read-only. Setup remains a technical alpha; if the documented versions or paths are different, get the prerequisites in place before bypassing a check. + +## Environment and installation + +| Symptom | What to check | +|---|---| +| `python` opens Microsoft Store, or Python cannot be found | Install Python 3.10+ from python.org with its launcher, reopen your terminal and rerun `SETUP.cmd`. | +| Missing `frida`, `PIL`, `numpy` or `capstone` | Run `SETUP.cmd` and use the repository's `.venv` Python. Installing into another Python environment will not satisfy the launch wrappers. | +| Package installation fails | Check the pip output and internet connectivity. Python 3.12 is the development baseline. Setup does not download game dependencies. | +| `.venv` is incomplete | Rename the incomplete `.venv` and rerun `SETUP.cmd`. Never move a virtual environment between checkouts; recreate it after moving the repository. | +| Missing lab, Godot or hooks | Edit `local_game_data/launcher.json`, using absolute paths. Check that the Godot field is the executable file, not its folder. | +| Bad JSON / invalid path | Use the format in `config/launcher.example.json`: forward slashes or doubled backslashes, double quotes and no trailing comma. | +| Original Steam build rejected | The preparer requires the verified Windows Steam 1.6.14 executable. GOG assets work with extraction, but the GOG executable route is unverified. Do not suppress the fingerprint gate. | +| Rollback patch rejected | Use the documented official `patch/Steam-1.6.14.bps`, not a patch for another storefront/version. | +| 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. | +| 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. | + +## Launch and VR + +| Symptom | What to check | +|---|---| +| SteamVR runtime error | Start SteamVR and select it as the active OpenXR runtime in SteamVR settings. The launcher reads that setting; it does not modify it. | +| Godot shows a scene but no live game | Use `RUN-VR.cmd` or `RUN-DESKTOP.cmd`; Godot alone does not start the native bridge. | +| Bridge timeout or exits immediately | Read `local_game_data/logs/bridge.log`. Confirm the lab is activated, its executable matches `hooks.json`, Hyperspace exists and extraction is complete. | +| “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. | +| 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. | +| Vulkan/client failure | Read `client.log` and `godot.log`; confirm the documented Godot executable and GPU driver. The client uses Godot's Vulkan Mobile renderer. | + +## Saves and game input + +The VR client uses the `hs_ftlvr_` profile in the normal FTL save directory. The launcher backs up that profile and isolated lab settings before each launch, under the lab's `save-backups/` directory. It does not make a complete backup of every ordinary FTL save. Make a manual full save-folder backup before your first run. + +Close the session before restoring a VR backup. Preserve a copy of the current profile first and restore only the intended matching VR files. If shutdown fails, FTL's last autosave may be the most recent recoverable state. Do not run smoke/native QA tools against a precious campaign. + +The bridge invokes handlers of the verified isolated FTL process. It does not drive your desktop mouse/keyboard or change the Windows keyboard layout. The floating renaming keyboard is QWERTY. Leave the lab's reserved input bindings and 1280×720 window unchanged. + +## Useful diagnostic files + +| Local file | Purpose | +|---|---| +| `local_game_data/logs/bridge.log` | Native process, Hyperspace state and capture/input bridge failures | +| `local_game_data/logs/client.log` | Godot client process output | +| `local_game_data/logs/godot.log` | Renderer/XR messages; search `FTLVR_XR_INPUT` for controller diagnostics | +| `local_game_data/bridge_status.json` | Session connection, heartbeat, state/capture freshness and errors | +| `local_game_data/xr_input.json` | Hand tracking, selected pose, interaction profile and button/axis values | +| `local_game_data/manifest.json` | Local asset extraction summary and source archive path | +| `/ftlvr-lab.json` | Local source installation, save prefix and activation marker | +| `/hooks.json` | Resolved local executable fingerprint and hook data | + +For a bug report, include the expected/actual behavior, reproduction steps, storefront/game version, Godot/Hyperspace versions, headset/controllers, GPU, SteamVR settings and relevant **short** log excerpts. A short gameplay recording can help diagnose pointing or panel placement. + +Review excerpts for your username, absolute filesystem paths or other personal information before posting. Do not attach game archives, executable copies, extracted game assets or full save folders. An issue report need not contain the entire local working directory. diff --git a/licenses/CAPSTONE-LICENSE.txt b/licenses/CAPSTONE-LICENSE.txt new file mode 100644 index 0000000..7522c2d --- /dev/null +++ b/licenses/CAPSTONE-LICENSE.txt @@ -0,0 +1,31 @@ +This is the software license for Capstone disassembly framework. +Capstone has been designed & implemented by Nguyen Anh Quynh + +See http://www.capstone-engine.org for further information. + +Copyright (c) 2013, COSEINC. +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +* Redistributions of source code must retain the above copyright notice, + this list of conditions and the following disclaimer. +* Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. +* Neither the name of the developer(s) nor the names of its + contributors may be used to endorse or promote products derived from this + software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE +LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +POSSIBILITY OF SUCH DAMAGE. diff --git a/licenses/FRIDA-COPYING.txt b/licenses/FRIDA-COPYING.txt new file mode 100644 index 0000000..05cd863 --- /dev/null +++ b/licenses/FRIDA-COPYING.txt @@ -0,0 +1,50 @@ + wxWindows Library Licence, Version 3.1 + ====================================== + +Copyright (c) 1998-2005 Julian Smart, Robert Roebling et al + +Everyone is permitted to copy and distribute verbatim copies +of this licence document, but changing it is not allowed. + + WXWINDOWS LIBRARY LICENCE + TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION + +This library is free software; you can redistribute it and/or modify it +under the terms of the GNU Library General Public Licence as published by +the Free Software Foundation; either version 2 of the Licence, or (at your +option) any later version. + +This library is distributed in the hope that it will be useful, but WITHOUT +ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +FITNESS FOR A PARTICULAR PURPOSE. See the GNU Library General Public +Licence for more details. + +You should have received a copy of the GNU Library General Public Licence +along with this software, usually in a file named COPYING.LIB. If not, +write to the Free Software Foundation, Inc., 51 Franklin Street, Fifth +Floor, Boston, MA 02110-1301 USA. + +EXCEPTION NOTICE + +1. As a special exception, the copyright holders of this library give +permission for additional uses of the text contained in this release of the +library as licenced under the wxWindows Library Licence, applying either +version 3.1 of the Licence, or (at your option) any later version of the +Licence as published by the copyright holders of version 3.1 of the Licence +document. + +2. The exception is that you may use, copy, link, modify and distribute +under your own terms, binary object code versions of works based on the +Library. + +3. If you copy code from files distributed under the terms of the GNU +General Public Licence or the GNU Library General Public Licence into a +copy of this library, as this licence permits, the exception does not apply +to the code that you add in this way. To avoid misleading anyone as to the +status of such modified files, you must delete this exception notice from +such code and/or adjust the licensing conditions notice accordingly. + +4. If you write modifications of your own for this library, it is your +choice whether to permit this exception to apply to your modifications. If +you do not wish that, you must delete the exception notice from such code +and/or adjust the licensing conditions notice accordingly. diff --git a/licenses/FTLMAN-LICENSE.txt b/licenses/FTLMAN-LICENSE.txt new file mode 100644 index 0000000..9efa6fb --- /dev/null +++ b/licenses/FTLMAN-LICENSE.txt @@ -0,0 +1,338 @@ + GNU GENERAL PUBLIC LICENSE + Version 2, June 1991 + + Copyright (C) 1989, 1991 Free Software Foundation, Inc., + + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The licenses for most software are designed to take away your +freedom to share and change it. By contrast, the GNU General Public +License is intended to guarantee your freedom to share and change free +software--to make sure the software is free for all its users. This +General Public License applies to most of the Free Software +Foundation's software and to any other program whose authors commit to +using it. (Some other Free Software Foundation software is covered by +the GNU Lesser General Public License instead.) You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +this service if you wish), that you receive source code or can get it +if you want it, that you can change the software or use pieces of it +in new free programs; and that you know you can do these things. + + To protect your rights, we need to make restrictions that forbid +anyone to deny you these rights or to ask you to surrender the rights. +These restrictions translate to certain responsibilities for you if you +distribute copies of the software, or if you modify it. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must give the recipients all the rights that +you have. You must make sure that they, too, receive or can get the +source code. And you must show them these terms so they know their +rights. + + We protect your rights with two steps: (1) copyright the software, and +(2) offer you this license which gives you legal permission to copy, +distribute and/or modify the software. + + Also, for each author's protection and ours, we want to make certain +that everyone understands that there is no warranty for this free +software. If the software is modified by someone else and passed on, we +want its recipients to know that what they have is not the original, so +that any problems introduced by others will not reflect on the original +authors' reputations. + + Finally, any free program is threatened constantly by software +patents. We wish to avoid the danger that redistributors of a free +program will individually obtain patent licenses, in effect making the +program proprietary. To prevent this, we have made it clear that any +patent must be licensed for everyone's free use or not licensed at all. + + The precise terms and conditions for copying, distribution and +modification follow. + + GNU GENERAL PUBLIC LICENSE + TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION + + 0. This License applies to any program or other work which contains +a notice placed by the copyright holder saying it may be distributed +under the terms of this General Public License. The "Program", below, +refers to any such program or work, and a "work based on the Program" +means either the Program or any derivative work under copyright law: +that is to say, a work containing the Program or a portion of it, +either verbatim or with modifications and/or translated into another +language. (Hereinafter, translation is included without limitation in +the term "modification".) Each licensee is addressed as "you". + +Activities other than copying, distribution and modification are not +covered by this License; they are outside its scope. The act of +running the Program is not restricted, and the output from the Program +is covered only if its contents constitute a work based on the +Program (independent of having been made by running the Program). +Whether that is true depends on what the Program does. + + 1. You may copy and distribute verbatim copies of the Program's +source code as you receive it, in any medium, provided that you +conspicuously and appropriately publish on each copy an appropriate +copyright notice and disclaimer of warranty; keep intact all the +notices that refer to this License and to the absence of any warranty; +and give any other recipients of the Program a copy of this License +along with the Program. + +You may charge a fee for the physical act of transferring a copy, and +you may at your option offer warranty protection in exchange for a fee. + + 2. You may modify your copy or copies of the Program or any portion +of it, thus forming a work based on the Program, and copy and +distribute such modifications or work under the terms of Section 1 +above, provided that you also meet all of these conditions: + + a) You must cause the modified files to carry prominent notices + stating that you changed the files and the date of any change. + + b) You must cause any work that you distribute or publish, that in + whole or in part contains or is derived from the Program or any + part thereof, to be licensed as a whole at no charge to all third + parties under the terms of this License. + + c) If the modified program normally reads commands interactively + when run, you must cause it, when started running for such + interactive use in the most ordinary way, to print or display an + announcement including an appropriate copyright notice and a + notice that there is no warranty (or else, saying that you provide + a warranty) and that users may redistribute the program under + these conditions, and telling the user how to view a copy of this + License. (Exception: if the Program itself is interactive but + does not normally print such an announcement, your work based on + the Program is not required to print an announcement.) + +These requirements apply to the modified work as a whole. If +identifiable sections of that work are not derived from the Program, +and can be reasonably considered independent and separate works in +themselves, then this License, and its terms, do not apply to those +sections when you distribute them as separate works. But when you +distribute the same sections as part of a whole which is a work based +on the Program, the distribution of the whole must be on the terms of +this License, whose permissions for other licensees extend to the +entire whole, and thus to each and every part regardless of who wrote it. + +Thus, it is not the intent of this section to claim rights or contest +your rights to work written entirely by you; rather, the intent is to +exercise the right to control the distribution of derivative or +collective works based on the Program. + +In addition, mere aggregation of another work not based on the Program +with the Program (or with a work based on the Program) on a volume of +a storage or distribution medium does not bring the other work under +the scope of this License. + + 3. You may copy and distribute the Program (or a work based on it, +under Section 2) in object code or executable form under the terms of +Sections 1 and 2 above provided that you also do one of the following: + + a) Accompany it with the complete corresponding machine-readable + source code, which must be distributed under the terms of Sections + 1 and 2 above on a medium customarily used for software interchange; or, + + b) Accompany it with a written offer, valid for at least three + years, to give any third party, for a charge no more than your + cost of physically performing source distribution, a complete + machine-readable copy of the corresponding source code, to be + distributed under the terms of Sections 1 and 2 above on a medium + customarily used for software interchange; or, + + c) Accompany it with the information you received as to the offer + to distribute corresponding source code. (This alternative is + allowed only for noncommercial distribution and only if you + received the program in object code or executable form with such + an offer, in accord with Subsection b above.) + +The source code for a work means the preferred form of the work for +making modifications to it. For an executable work, complete source +code means all the source code for all modules it contains, plus any +associated interface definition files, plus the scripts used to +control compilation and installation of the executable. However, as a +special exception, the source code distributed need not include +anything that is normally distributed (in either source or binary +form) with the major components (compiler, kernel, and so on) of the +operating system on which the executable runs, unless that component +itself accompanies the executable. + +If distribution of executable or object code is made by offering +access to copy from a designated place, then offering equivalent +access to copy the source code from the same place counts as +distribution of the source code, even though third parties are not +compelled to copy the source along with the object code. + + 4. You may not copy, modify, sublicense, or distribute the Program +except as expressly provided under this License. Any attempt +otherwise to copy, modify, sublicense or distribute the Program is +void, and will automatically terminate your rights under this License. +However, parties who have received copies, or rights, from you under +this License will not have their licenses terminated so long as such +parties remain in full compliance. + + 5. You are not required to accept this License, since you have not +signed it. However, nothing else grants you permission to modify or +distribute the Program or its derivative works. These actions are +prohibited by law if you do not accept this License. Therefore, by +modifying or distributing the Program (or any work based on the +Program), you indicate your acceptance of this License to do so, and +all its terms and conditions for copying, distributing or modifying +the Program or works based on it. + + 6. Each time you redistribute the Program (or any work based on the +Program), the recipient automatically receives a license from the +original licensor to copy, distribute or modify the Program subject to +these terms and conditions. You may not impose any further +restrictions on the recipients' exercise of the rights granted herein. +You are not responsible for enforcing compliance by third parties to +this License. + + 7. If, as a consequence of a court judgment or allegation of patent +infringement or for any other reason (not limited to patent issues), +conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot +distribute so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you +may not distribute the Program at all. For example, if a patent +license would not permit royalty-free redistribution of the Program by +all those who receive copies directly or indirectly through you, then +the only way you could satisfy both it and this License would be to +refrain entirely from distribution of the Program. + +If any portion of this section is held invalid or unenforceable under +any particular circumstance, the balance of the section is intended to +apply and the section as a whole is intended to apply in other +circumstances. + +It is not the purpose of this section to induce you to infringe any +patents or other property right claims or to contest validity of any +such claims; this section has the sole purpose of protecting the +integrity of the free software distribution system, which is +implemented by public license practices. Many people have made +generous contributions to the wide range of software distributed +through that system in reliance on consistent application of that +system; it is up to the author/donor to decide if he or she is willing +to distribute software through any other system and a licensee cannot +impose that choice. + +This section is intended to make thoroughly clear what is believed to +be a consequence of the rest of this License. + + 8. If the distribution and/or use of the Program is restricted in +certain countries either by patents or by copyrighted interfaces, the +original copyright holder who places the Program under this License +may add an explicit geographical distribution limitation excluding +those countries, so that distribution is permitted only in or among +countries not thus excluded. In such case, this License incorporates +the limitation as if written in the body of this License. + + 9. The Free Software Foundation may publish revised and/or new versions +of the General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + +Each version is given a distinguishing version number. If the Program +specifies a version number of this License which applies to it and "any +later version", you have the option of following the terms and conditions +either of that version or of any later version published by the Free +Software Foundation. If the Program does not specify a version number of +this License, you may choose any version ever published by the Free Software +Foundation. + + 10. If you wish to incorporate parts of the Program into other free +programs whose distribution conditions are different, write to the author +to ask for permission. For software which is copyrighted by the Free +Software Foundation, write to the Free Software Foundation; we sometimes +make exceptions for this. Our decision will be guided by the two goals +of preserving the free status of all derivatives of our free software and +of promoting the sharing and reuse of software generally. + + NO WARRANTY + + 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY +FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN +OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES +PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED +OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS +TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE +PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, +REPAIR OR CORRECTION. + + 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR +REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, +INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING +OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED +TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY +YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER +PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE +POSSIBILITY OF SUCH DAMAGES. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +convey the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation; either version 2 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along + with this program; if not, see . + +Also add information on how to contact you by electronic and paper mail. + +If the program is interactive, make it output a short notice like this +when it starts in an interactive mode: + + Gnomovision version 69, Copyright (C) year name of author + Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, the commands you use may +be called something other than `show w' and `show c'; they could even be +mouse-clicks or menu items--whatever suits your program. + +You should also get your employer (if you work as a programmer) or your +school, if any, to sign a "copyright disclaimer" for the program, if +necessary. Here is a sample; alter the names: + + Yoyodyne, Inc., hereby disclaims all copyright interest in the program + `Gnomovision' (which makes passes at compilers) written by James Hacker. + + , 1 April 1989 + Moe Ghoul, President of Vice + +This General Public License does not permit incorporating your program into +proprietary programs. If your program is a subroutine library, you may +consider it more useful to permit linking proprietary applications with the +library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. diff --git a/licenses/GODOT-LICENSE.txt b/licenses/GODOT-LICENSE.txt new file mode 100644 index 0000000..b0f00de --- /dev/null +++ b/licenses/GODOT-LICENSE.txt @@ -0,0 +1,21 @@ +Copyright (c) 2014-present Godot Engine contributors (see AUTHORS.md). +Copyright (c) 2007-2014 Juan Linietsky, Ariel Manzur. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + diff --git a/licenses/GODOT_COPYRIGHT.txt b/licenses/GODOT_COPYRIGHT.txt new file mode 100644 index 0000000..ef22b6d --- /dev/null +++ b/licenses/GODOT_COPYRIGHT.txt @@ -0,0 +1,2795 @@ +Godot Engine dependency copyright and license information +Engine: 4.7.2-stable (official) +Build hash: ed1daf0bf001b61586d9930840f2f1394092c079 + +Generated from Engine.get_copyright_info() and Engine.get_license_info() +in the inspected standard Windows engine executable. All returned entries +and complete license definitions are included below. Refresh this document +when the bundled engine build changes. + +COMPONENT COPYRIGHT INFORMATION +=============================== + +Godot Engine +------------ +Files: + * +Copyright: + 2014-present, Godot Engine contributors + 2007-2014, Juan Linietsky, Ariel Manzur +License: Expat + + +Bullet Continuous Collision Detection and Physics Library +--------------------------------------------------------- +Files: + core/math/convex_hull.cpp + core/math/convex_hull.h +Copyright: + 2011, Ole Kniemeyer, MAXON, www.maxon.net + 2014-present, Godot Engine contributors + 2007-2014, Juan Linietsky, Ariel Manzur +License: Expat and Zlib + +Files: + modules/godot_physics_3d/gjk_epa.cpp + modules/godot_physics_3d/joints/godot_generic_6dof_joint_3d.cpp + modules/godot_physics_3d/joints/godot_generic_6dof_joint_3d.h + modules/godot_physics_3d/joints/godot_hinge_joint_3d.cpp + modules/godot_physics_3d/joints/godot_hinge_joint_3d.h + modules/godot_physics_3d/joints/godot_jacobian_entry_3d.h + modules/godot_physics_3d/joints/godot_pin_joint_3d.cpp + modules/godot_physics_3d/joints/godot_pin_joint_3d.h + modules/godot_physics_3d/joints/godot_slider_joint_3d.cpp + modules/godot_physics_3d/joints/godot_slider_joint_3d.h + modules/godot_physics_3d/godot_soft_body_3d.cpp + modules/godot_physics_3d/godot_soft_body_3d.h + modules/godot_physics_3d/godot_shape_3d.cpp + modules/godot_physics_3d/godot_shape_3d.h +Copyright: + 2003-2008, Erwin Coumans + 2014-present, Godot Engine contributors + 2007-2014, Juan Linietsky, Ariel Manzur +License: Expat and Zlib + +Files: + modules/godot_physics_3d/joints/godot_cone_twist_joint_3d.cpp + modules/godot_physics_3d/joints/godot_cone_twist_joint_3d.h +Copyright: + 2007, Starbreeze Studios + 2014-present, Godot Engine contributors + 2007-2014, Juan Linietsky, Ariel Manzur +License: Expat and Zlib + + +Linux AppStream Metadata File +----------------------------- +Files: + misc/dist/linux/org.godotengine.Godot.appdata.xml +Copyright: + 2017-2022, Rémi Verschelde +License: CC0-1.0 + + +Godot Engine logo +----------------- +Files: + misc/logo/* +Copyright: + 2017, Andrea Calabró +License: CC-BY-4.0 + + +Betsy +----- +Files: + modules/betsy/alpha_stitch.glsl + modules/betsy/bc1.glsl + modules/betsy/bc4.glsl + modules/betsy/bc6h.glsl +Copyright: + 2020-2022, Matias N. Goldberg +License: Expat + + +Chipmunk2D Joint Constraints +---------------------------- +Files: + modules/godot_physics_2d/godot_joints_2d.cpp +Copyright: + 2007, Scott Lembcke +License: Expat + + +Open Dynamics Engine +-------------------- +Files: + modules/godot_physics_3d/godot_collision_solver_3d_sat.cpp +Copyright: + 2001-2003, Russell L. Smith, Alen Ladavac, Nguyen Binh +License: BSD-3-clause + + +Jolt Physics +------------ +Files: + modules/jolt_physics/spaces/jolt_temp_allocator.cpp +Copyright: + 2021, Jorrit Rouwe + 2014-present, Godot Engine contributors + 2007-2014, Juan Linietsky, Ariel Manzur +License: Expat + +Files: + thirdparty/jolt_physics/* +Copyright: + 2021, Jorrit Rouwe +License: Expat + + +Joint Non-Local Means (JNLM) denoiser +------------------------------------- +Files: + modules/lightmapper_rd/lm_compute.glsl +Copyright: + 2020, Manuel Prandini + 2014-present, Godot Engine contributors + 2007-2014, Juan Linietsky, Ariel Manzur +License: Expat + + +The Android Open Source Project +------------------------------- +Files: + platform/android/java/editor/src/main/java/com/android/* + platform/android/java/lib/src/main/aidl/com/android/* + platform/android/java/lib/src/main/res/layout/status_bar_ongoing_event_progress_bar.xml + platform/android/java/lib/src/main/java/com/google/android/* +Copyright: + 2008-2016, The Android Open Source Project + 2002, Google, Inc. +License: Apache-2.0 + + +ProcessPhoenix +-------------- +Files: + platform/android/java/lib/src/main/java/org/godotengine/godot/utils/ProcessPhoenix.java +Copyright: + 2015, Jake Wharton +License: Apache-2.0 + + +Robert Penner's Easing Functions +-------------------------------- +Files: + scene/animation/easing_equations.h +Copyright: + 2001, Robert Penner + 2014-present, Godot Engine contributors + 2007-2014, Juan Linietsky, Ariel Manzur +License: Expat + + +NVidia's FXAA 3.11, simplified by Simon Rodriguez +------------------------------------------------- +Files: + servers/rendering/renderer_rd/shaders/effects/tonemap.glsl +Copyright: + 2014-2015, NVIDIA CORPORATION + 2017 Simon Rodriguez +License: BSD-3-clause and Expat + + +Intel ASSAO and related files +----------------------------- +Files: + servers/rendering/renderer_rd/shaders/effects/ss_effects_downsample.glsl + servers/rendering/renderer_rd/shaders/effects/ssao_blur.glsl + servers/rendering/renderer_rd/shaders/effects/ssao_importance_map.glsl + servers/rendering/renderer_rd/shaders/effects/ssao_interleave.glsl + servers/rendering/renderer_rd/shaders/effects/ssao.glsl + servers/rendering/renderer_rd/shaders/effects/ssil_blur.glsl + servers/rendering/renderer_rd/shaders/effects/ssil_importance_map.glsl + servers/rendering/renderer_rd/shaders/effects/ssil_interleave.glsl + servers/rendering/renderer_rd/shaders/effects/ssil.glsl +Copyright: + 2016, Intel Corporation +License: Expat + + +Temporal Anti-Aliasing resolve implementation +--------------------------------------------- +Files: + servers/rendering/renderer_rd/shaders/effects/taa_resolve.glsl +Copyright: + 2016, Panos Karabelas +License: Expat + + +Subpixel Morphological Antialiasing +----------------------------------- +Files: + servers/rendering/renderer_rd/shaders/effects/smaa_blending.glsl + servers/rendering/renderer_rd/shaders/effects/smaa_weight_calculation.glsl + servers/rendering/renderer_rd/shaders/effects/smaa_edge_detection.glsl + thirdparty/smaa/* +Copyright: + 2013, Jorge Jimenez + 2013, Jose I. Echevarria + 2013, Belen Masia + 2013, Fernando Navarro + 2013, Diego Gutierrez +License: Expat + + +AccessKit +--------- +Files: + thirdparty/accesskit/* +Copyright: + 2023, The AccessKit Authors. +License: Expat + + +AMD FidelityFX Super Resolution +------------------------------- +Files: + thirdparty/amd-fsr/* +Copyright: + 2021, Advanced Micro Devices, Inc. +License: Expat + + +AMD FidelityFX Super Resolution 2 +--------------------------------- +Files: + thirdparty/amd-fsr2/* +Copyright: + 2022-2023, Advanced Micro Devices, Inc. +License: Expat + + +ANGLE +----- +Files: + thirdparty/angle/* +Copyright: + 2018, The ANGLE Project Authors. +License: BSD-3-clause + + +Arm ASTC Encoder +---------------- +Files: + thirdparty/astcenc/* +Copyright: + 2011-2025, Arm Limited +License: Apache-2.0 + + +Basis Universal +--------------- +Files: + thirdparty/basis_universal/* +Copyright: + 2019-2025, Binomial LLC. +License: Apache-2.0 + + +Brotli +------ +Files: + thirdparty/brotli/* +Copyright: + 2009, 2010, 2013-2016 by the Brotli Authors. +License: Expat + + +CA certificates +--------------- +Files: + thirdparty/certs/ca-bundle.crt +Copyright: + Mozilla Contributors +License: MPL-2.0 + + +Clipper2 +-------- +Files: + thirdparty/clipper2/* +Copyright: + 2010-2025, Angus Johnson +License: BSL-1.0 + + +Convection Texture Tools Stand-Alone Kernels +-------------------------------------------- +Files: + thirdparty/cvtt/* +Copyright: + 2018, Eric Lasota + 2018, Microsoft Corp. +License: Expat + + +D3D12 Memory Allocator +---------------------- +Files: + thirdparty/d3d12ma/* +Copyright: + 2019-2022 Advanced Micro Devices, Inc. +License: Expat + + +DirectX Headers +--------------- +Files: + thirdparty/directx_headers/* +Copyright: + Microsoft Corporation +License: Expat + + +doctest +------- +Files: + thirdparty/doctest/* +Copyright: + 2016-2023, Viktor Kirilov +License: Expat + + +dr_libs +------- +Files: + thirdparty/dr_libs/* +Copyright: + 2020, David Reid +License: Unlicense or MIT-0 + + +Embree +------ +Files: + thirdparty/embree/* +Copyright: + 2009-2021 Intel Corporation +License: Apache-2.0 + + +ENet +---- +Files: + thirdparty/enet/* +Copyright: + 2002-2024, Lee Salzman +License: Expat + + +etcpak +------ +Files: + thirdparty/etcpak/* +Copyright: + 2013-2022, Bartosz Taudul +License: BSD-3-clause + + +DroidSans font +-------------- +Files: + thirdparty/fonts/DroidSans*.woff2 +Copyright: + 2008, The Android Open Source Project +License: Apache-2.0 + + +Inter font +---------- +Files: + thirdparty/fonts/Inter*.woff2 +Copyright: + 2016, The Inter Project Authors +License: OFL-1.1 + + +JetBrains Mono font +------------------- +Files: + thirdparty/fonts/JetBrainsMono_Regular.woff2 +Copyright: + 2020, JetBrains s.r.o. +License: OFL-1.1 + + +Noto Sans font +-------------- +Files: + thirdparty/fonts/NotoSans*.woff2 +Copyright: + 2012, Google Inc. +License: OFL-1.1 + + +Open Sans font +-------------- +Files: + thirdparty/fonts/OpenSans*.woff2 +Copyright: + 2020, The Open Sans Project Authors +License: OFL-1.1 + + +Vazirmatn font +-------------- +Files: + thirdparty/fonts/Vazirmatn*.woff2 +Copyright: + 2015, The Vazirmatn Project Authors. +License: OFL-1.1 + + +The FreeType Project +-------------------- +Files: + thirdparty/freetype/* +Copyright: + 1996-2025, David Turner, Robert Wilhelm, and Werner Lemberg. +License: FTL + + +GamepadMotionHelpers +-------------------- +Files: + thirdparty/gamepadmotionhelpers/* +Copyright: + 2020-2023, Julian "Jibb" Smart +License: Expat + + +glad +---- +Files: + thirdparty/glad/* +Copyright: + 2013-2022, David Herberth + 2013-2020, The Khronos Group Inc. +License: CC0-1.0 and Apache-2.0 + + +glslang +------- +Files: + thirdparty/glslang/* +Copyright: + 2015-2020, Google, Inc. + 2014-2020, The Khronos Group Inc + 2002, NVIDIA Corporation. +License: glslang + + +Graphite engine +--------------- +Files: + thirdparty/graphite/* +Copyright: + 2010, SIL International +License: Expat + + +Grisu2 float serialization algorithm +------------------------------------ +Files: + thirdparty/grisu2/* +Copyright: + 2009, Florian Loitsch + 2018-2023, The simdjson authors +License: Expat and Apache-2.0 + + +HarfBuzz text shaping library +----------------------------- +Files: + thirdparty/harfbuzz/* +Copyright: + 2010-2022, Google, Inc. + 2015-2020, Ebrahim Byagowi + 2019,2020, Facebook, Inc. + 2012, 2015, Mozilla Foundation + 2011, Codethink Limited + 2008, 2010, Nokia Corporation and/or its subsidiary(-ies) + 2009, Keith Stribley + 2011, Martin Hosken and SIL International + 2007, Chris Wilson + 2005-2006, 2020-2023, Behdad Esfahbod + 2004, 2007-2010, 2013, 2021-2023, Red Hat, Inc. + 1998-2005, David Turner and Werner Lemberg + 2016, Igalia, S.L. + 2022, Matthias Clasen + 2018, 2021, Khaled Hosny + 2018-2020, Adobe, Inc. + 2013-2015, Alexei Podtelezhnikov +License: HarfBuzz + + +International Components for Unicode +------------------------------------ +Files: + thirdparty/icu4c/* +Copyright: + 2016-2024, Unicode, Inc. +License: Unicode + + +libbacktrace +------------ +Files: + thirdparty/libbacktrace/* +Copyright: + 2012-2021, Free Software Foundation, Inc. +License: BSD-3-clause + + +libjpeg-turbo +------------- +Files: + thirdparty/libjpeg-turbo/* +Copyright: + 2009-2025, D. R. Commander + 2015, Viktor Szathmáry. + 1991-2020, Thomas G. Lane, Guido Vollbeding +License: BSD-3-clause and IJG + + +KTX +--- +Files: + thirdparty/libktx/* +Copyright: + 2013-2020, Mark Callow + 2010-2020 The Khronos Group, Inc. +License: Apache-2.0 + + +OggVorbis +--------- +Files: + thirdparty/libogg/* +Copyright: + 2002, Xiph.org Foundation +License: BSD-3-clause + +Files: + thirdparty/libvorbis/* +Copyright: + 2002-2015, Xiph.org Foundation +License: BSD-3-clause + + +libpng +------ +Files: + thirdparty/libpng/* +Copyright: + 1995-2026, The PNG Reference Library Authors. + 2018-2026, Cosmin Truta. + 2000-2002, 2004, 2006-2018 Glenn Randers-Pehrson. + 1996-1997, Andreas Dilger. + 1995-1996, Guy Eric Schalnat, Group 42, Inc. +License: Zlib + + +OggTheora +--------- +Files: + thirdparty/libtheora/* +Copyright: + 2002-2009, Xiph.org Foundation +License: BSD-3-clause + + +WebP codec +---------- +Files: + thirdparty/libwebp/* +Copyright: + 2010, Google Inc. +License: BSD-3-clause + + +Manifold +-------- +Files: + thirdparty/manifold/* +Copyright: + 2020-2025, The Manifold Authors +License: Apache-2.0 + + +Mbed TLS +-------- +Files: + thirdparty/mbedtls/* +Copyright: + The Mbed TLS Contributors +License: Apache-2.0 + + +meshoptimizer +------------- +Files: + thirdparty/meshoptimizer/* +Copyright: + 2016-2026, Arseny Kapoulkine +License: Expat + + +metal-cpp +--------- +Files: + thirdparty/metal-cpp/* +Copyright: + 2024, Apple Inc. +License: Apache-2.0 + + +mingw-std-threads +----------------- +Files: + thirdparty/mingw-std-threads/* +Copyright: + 2016, Mega Limited +License: BSD-2-clause + + +MiniUPnP Project +---------------- +Files: + thirdparty/miniupnpc/* +Copyright: + 2005-2025, Thomas Bernard +License: BSD-3-clause + + +MiniZip +------- +Files: + thirdparty/minizip/* +Copyright: + 1998-2026, Gilles Vollant + 2007-2008, Even Rouault + 2009-2010, Mathias Svensson +License: Zlib + + +bcdec +----- +Files: + thirdparty/misc/bcdec.h +Copyright: + 2022, Sergii Kudlai +License: Expat + + +Fast Filtering of Reflection Probes +----------------------------------- +Files: + thirdparty/misc/cubemap_coeffs.h +Copyright: + 2016, Activision Publishing, Inc. +License: Expat + + +FastLZ +------ +Files: + thirdparty/misc/fastlz.c + thirdparty/misc/fastlz.h +Copyright: + 2005-2020, Ariya Hidayat +License: Expat + + +FastNoise Lite +-------------- +Files: + thirdparty/misc/FastNoiseLite.h +Copyright: + 2023, Jordan Peck and contributors +License: Expat + + +libjingle +--------- +Files: + thirdparty/misc/ifaddrs-android.cc + thirdparty/misc/ifaddrs-android.h +Copyright: + 2012-2013, Google Inc. +License: BSD-3-clause + + +Tangent Space Normal Maps implementation +---------------------------------------- +Files: + thirdparty/misc/mikktspace.c + thirdparty/misc/mikktspace.h +Copyright: + 2011, Morten S. Mikkelsen +License: Zlib + + +NVIDIA NVAPI (minimal excerpt) +------------------------------ +Files: + thirdparty/misc/nvapi_minimal.h +Copyright: + 2019-2022, NVIDIA Corporation +License: Expat + + +OK Lab color space +------------------ +Files: + thirdparty/misc/ok_color.h + thirdparty/misc/ok_color_shader.h +Copyright: + 2021, Björn Ottosson +License: Expat + + +Minimal PCG32 implementation +---------------------------- +Files: + thirdparty/misc/pcg.cpp + thirdparty/misc/pcg.h +Copyright: + 2014, M.E. O'Neill +License: Apache-2.0 + + +PolyPartition / Triangulator +---------------------------- +Files: + thirdparty/misc/polypartition.cpp + thirdparty/misc/polypartition.h +Copyright: + 2011-2021, Ivan Fratric and contributors +License: Expat + + +Quite OK Audio Format +--------------------- +Files: + thirdparty/misc/qoa.h +Copyright: + 2023, Dominic Szablewski +License: Expat + + +r128 library +------------ +Files: + thirdparty/misc/r128.c + thirdparty/misc/r128.h +Copyright: + Alan Hickman +License: Unlicense + + +SMAZ +---- +Files: + thirdparty/misc/smaz.c + thirdparty/misc/smaz.h +Copyright: + 2006-2009, Salvatore Sanfilippo +License: BSD-3-clause + + +SMOL-V +------ +Files: + thirdparty/misc/smolv.cpp + thirdparty/misc/smolv.h +Copyright: + 2016-2024, Aras Pranckevicius +License: Unlicense or Expat + + +stb libraries +------------- +Files: + thirdparty/misc/stb_rect_pack.h +Copyright: + Sean Barrett +License: Unlicense or Expat + + +YUV2RGB +------- +Files: + thirdparty/misc/yuv2rgb.h +Copyright: + 2008-2011, Robin Watts +License: BSD-2-clause + + +Multi-channel signed distance field generator +--------------------------------------------- +Files: + thirdparty/msdfgen/* +Copyright: + 2014-2025, Viktor Chlumsky +License: Expat + + +OpenXR Loader +------------- +Files: + thirdparty/openxr/* +Copyright: + 2020-2025, The Khronos Group Inc. +License: Apache-2.0 + + +PCRE2 +----- +Files: + thirdparty/pcre2/* +Copyright: + 1997-2024, University of Cambridge + 2009-2024, Zoltan Herczeg +License: BSD-3-clause + + +Recast +------ +Files: + thirdparty/recastnavigation/* +Copyright: + 2009, Mikko Mononen +License: Zlib + + +RVO2 +---- +Files: + thirdparty/rvo2/* +Copyright: + 2016, University of North Carolina at Chapel Hill +License: Apache-2.0 + + +SDL +--- +Files: + thirdparty/sdl/* +Copyright: + 1997-2025, Sam Lantinga +License: Zlib + + +hidapi +------ +Files: + thirdparty/sdl/hidapi/* +Copyright: + 2010, Alan Ott, Signal 11 Software +License: BSD-3-clause + + +SPIRV-Cross +----------- +Files: + thirdparty/spirv-cross/* +Copyright: + 2015-2021, Arm Limited +License: Apache-2.0 or Expat + + +SPIRV-Headers +------------- +Files: + thirdparty/spirv-headers/* +Copyright: + 2015-2024, The Khronos Group Inc. +License: Expat + + +SPIRV-Reflect +------------- +Files: + thirdparty/spirv-reflect/* +Copyright: + 2017-2022, Google Inc. +License: Apache-2.0 + + +Swappy +------ +Files: + thirdparty/swappy-frame-pacing/* +Copyright: + 2019, The Android Open Source Project +License: Apache-2.0 + + +ThorVG +------ +Files: + thirdparty/thorvg/* +Copyright: + 2020-2026, The ThorVG Project +License: Expat + + +TinyEXR +------- +Files: + thirdparty/tinyexr/* +Copyright: + 2014-2021, Syoyo Fujita + 2002, Industrial Light & Magic, a division of Lucas Digital Ltd. LLC +License: BSD-3-clause + + +ufbx +---- +Files: + thirdparty/ufbx/* +Copyright: + 2020, Samuli Raivio +License: Expat + + +V-HACD +------ +Files: + thirdparty/vhacd/* +Copyright: + 2011, Khaled Mamou + 2003-2009, Erwin Coumans +License: BSD-3-clause + + +volk +---- +Files: + thirdparty/volk/* +Copyright: + 2018-2025, Arseny Kapoulkine +License: Expat + + +Vulkan Headers +-------------- +Files: + thirdparty/vulkan/* +Copyright: + 2015-2025, The Khronos Group Inc. + 2015-2025, Valve Corporation + 2015-2025, LunarG, Inc. +License: Apache-2.0 + + +Vulkan Memory Allocator +----------------------- +Files: + thirdparty/vulkan/vk_mem_alloc.h +Copyright: + 2017-2025, Advanced Micro Devices, Inc. +License: Expat + + +Wayland core protocol +--------------------- +Files: + thirdparty/wayland/* +Copyright: + 2008-2012, Kristian Høgsberg + 2010-2012, Intel Corporation + 2011, Benjamin Franzke + 2012, Collabora, Ltd. +License: Expat + + +Wayland protocols +----------------- +Files: + thirdparty/wayland-protocols/* +Copyright: + 2008-2013, Kristian Høgsberg + 2010-2013, Intel Corporation + 2013, Rafael Antognolli + 2013, Jasper St. Pierre + 2014, Jonas Ådahl + 2014, Jason Ekstrand + 2014-2015, Collabora, Ltd. + 2015, Red Hat Inc. +License: Expat + + +Mesa Wayland protocols +---------------------- +Files: + thirdparty/wayland-protocols/mesa/wayland-drm.xml +Copyright: + 2008-2011, Kristian Høgsberg + 2010-2011, Intel Corporation +License: X11 + + +Wslay +----- +Files: + thirdparty/wslay/* +Copyright: + 2011, 2012, 2015, Tatsuhiro Tsujikawa +License: Expat + + +xatlas +------ +Files: + thirdparty/xatlas/* +Copyright: + 2018-2020, Jonathan Young + 2013, Thekla, Inc + 2006, NVIDIA Corporation, Ignacio Castano +License: Expat + + +zlib +---- +Files: + thirdparty/zlib/* +Copyright: + 1995-2026, Jean-loup Gailly and Mark Adler +License: Zlib + + +Zstandard +--------- +Files: + thirdparty/zstd/* +Copyright: + Meta Platforms, Inc. and affiliates. +License: BSD-3-clause + + +FULL LICENSE DEFINITIONS +======================== + +Apache-2.0 +---------- +Apache License +Version 2.0, January 2004 +http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + +"License" shall mean the terms and conditions for use, reproduction, +and distribution as defined by Sections 1 through 9 of this document. + +"Licensor" shall mean the copyright owner or entity authorized by +the copyright owner that is granting the License. + +"Legal Entity" shall mean the union of the acting entity and all +other entities that control, are controlled by, or are under common +control with that entity. For the purposes of this definition, +"control" means (i) the power, direct or indirect, to cause the +direction or management of such entity, whether by contract or +otherwise, or (ii) ownership of fifty percent (50%) or more of the +outstanding shares, or (iii) beneficial ownership of such entity. + +"You" (or "Your") shall mean an individual or Legal Entity +exercising permissions granted by this License. + +"Source" form shall mean the preferred form for making modifications, +including but not limited to software source code, documentation +source, and configuration files. + +"Object" form shall mean any form resulting from mechanical +transformation or translation of a Source form, including but +not limited to compiled object code, generated documentation, +and conversions to other media types. + +"Work" shall mean the work of authorship, whether in Source or +Object form, made available under the License, as indicated by a +copyright notice that is included in or attached to the work +(an example is provided in the Appendix below). + +"Derivative Works" shall mean any work, whether in Source or Object +form, that is based on (or derived from) the Work and for which the +editorial revisions, annotations, elaborations, or other modifications +represent, as a whole, an original work of authorship. For the purposes +of this License, Derivative Works shall not include works that remain +separable from, or merely link (or bind by name) to the interfaces of, +the Work and Derivative Works thereof. + +"Contribution" shall mean any work of authorship, including +the original version of the Work and any modifications or additions +to that Work or Derivative Works thereof, that is intentionally +submitted to Licensor for inclusion in the Work by the copyright owner +or by an individual or Legal Entity authorized to submit on behalf of +the copyright owner. For the purposes of this definition, "submitted" +means any form of electronic, verbal, or written communication sent +to the Licensor or its representatives, including but not limited to +communication on electronic mailing lists, source code control systems, +and issue tracking systems that are managed by, or on behalf of, the +Licensor for the purpose of discussing and improving the Work, but +excluding communication that is conspicuously marked or otherwise +designated in writing by the copyright owner as "Not a Contribution." + +"Contributor" shall mean Licensor and any individual or Legal Entity +on behalf of whom a Contribution has been received by Licensor and +subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of +this License, each Contributor hereby grants to You a perpetual, +worldwide, non-exclusive, no-charge, royalty-free, irrevocable +copyright license to reproduce, prepare Derivative Works of, +publicly display, publicly perform, sublicense, and distribute the +Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of +this License, each Contributor hereby grants to You a perpetual, +worldwide, non-exclusive, no-charge, royalty-free, irrevocable +(except as stated in this section) patent license to make, have made, +use, offer to sell, sell, import, and otherwise transfer the Work, +where such license applies only to those patent claims licensable +by such Contributor that are necessarily infringed by their +Contribution(s) alone or by combination of their Contribution(s) +with the Work to which such Contribution(s) was submitted. If You +institute patent litigation against any entity (including a +cross-claim or counterclaim in a lawsuit) alleging that the Work +or a Contribution incorporated within the Work constitutes direct +or contributory patent infringement, then any patent licenses +granted to You under this License for that Work shall terminate +as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the +Work or Derivative Works thereof in any medium, with or without +modifications, and in Source or Object form, provided that You +meet the following conditions: + +(a) You must give any other recipients of the Work or +Derivative Works a copy of this License; and + +(b) You must cause any modified files to carry prominent notices +stating that You changed the files; and + +(c) You must retain, in the Source form of any Derivative Works +that You distribute, all copyright, patent, trademark, and +attribution notices from the Source form of the Work, +excluding those notices that do not pertain to any part of +the Derivative Works; and + +(d) If the Work includes a "NOTICE" text file as part of its +distribution, then any Derivative Works that You distribute must +include a readable copy of the attribution notices contained +within such NOTICE file, excluding those notices that do not +pertain to any part of the Derivative Works, in at least one +of the following places: within a NOTICE text file distributed +as part of the Derivative Works; within the Source form or +documentation, if provided along with the Derivative Works; or, +within a display generated by the Derivative Works, if and +wherever such third-party notices normally appear. The contents +of the NOTICE file are for informational purposes only and +do not modify the License. You may add Your own attribution +notices within Derivative Works that You distribute, alongside +or as an addendum to the NOTICE text from the Work, provided +that such additional attribution notices cannot be construed +as modifying the License. + +You may add Your own copyright statement to Your modifications and +may provide additional or different license terms and conditions +for use, reproduction, or distribution of Your modifications, or +for any such Derivative Works as a whole, provided Your use, +reproduction, and distribution of the Work otherwise complies with +the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, +any Contribution intentionally submitted for inclusion in the Work +by You to the Licensor shall be under the terms and conditions of +this License, without any additional terms or conditions. +Notwithstanding the above, nothing herein shall supersede or modify +the terms of any separate license agreement you may have executed +with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade +names, trademarks, service marks, or product names of the Licensor, +except as required for reasonable and customary use in describing the +origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or +agreed to in writing, Licensor provides the Work (and each +Contributor provides its Contributions) on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +implied, including, without limitation, any warranties or conditions +of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A +PARTICULAR PURPOSE. You are solely responsible for determining the +appropriateness of using or redistributing the Work and assume any +risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, +whether in tort (including negligence), contract, or otherwise, +unless required by applicable law (such as deliberate and grossly +negligent acts) or agreed to in writing, shall any Contributor be +liable to You for damages, including any direct, indirect, special, +incidental, or consequential damages of any character arising as a +result of this License or out of the use or inability to use the +Work (including but not limited to damages for loss of goodwill, +work stoppage, computer failure or malfunction, or any and all +other commercial damages or losses), even if such Contributor +has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing +the Work or Derivative Works thereof, You may choose to offer, +and charge a fee for, acceptance of support, warranty, indemnity, +or other liability obligations and/or rights consistent with this +License. However, in accepting such obligations, You may act only +on Your own behalf and on Your sole responsibility, not on behalf +of any other Contributor, and only if You agree to indemnify, +defend, and hold each Contributor harmless for any liability +incurred by, or claims asserted against, such Contributor by reason +of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + +To apply the Apache License to your work, attach the following +boilerplate notice, with the fields enclosed by brackets "[]" +replaced with your own identifying information. (Don't include +the brackets!) The text should be enclosed in the appropriate +comment syntax for the file format. We also recommend that a +file or class name and description of purpose be included on the +same "printed page" as the copyright notice for easier +identification within third-party archives. + +Copyright [yyyy] [name of copyright owner] + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + + +BSD-2-clause +------------ +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +* Redistributions of source code must retain the above copyright notice, this +list of conditions and the following disclaimer. + +* Redistributions in binary form must reproduce the above copyright notice, +this list of conditions and the following disclaimer in the documentation +and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + + +BSD-3-clause +------------ +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: + +1. Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright +notice, this list of conditions and the following disclaimer in the +documentation and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE REGENTS AND CONTRIBUTORS ``AS IS'' AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +ARE DISCLAIMED. IN NO EVENT SHALL THE REGENTS OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS +OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) +HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY +OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF +SUCH DAMAGE. + + +BSL-1.0 +------- +Boost Software License - Version 1.0 - August 17th, 2003 + +Permission is hereby granted, free of charge, to any person or organization +obtaining a copy of the software and accompanying documentation covered by +this license (the "Software") to use, reproduce, display, distribute, +execute, and transmit the Software, and to prepare derivative works of the +Software, and to permit third-parties to whom the Software is furnished to +do so, all subject to the following: + +The copyright notices in the Software and this entire statement, including +the above license grant, this restriction and the following disclaimer, +must be included in all copies of the Software, in whole or in part, and +all derivative works of the Software, unless such copies or derivative +works are solely in the form of machine-executable object code generated by +a source language processor. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE, TITLE AND NON-INFRINGEMENT. IN NO EVENT +SHALL THE COPYRIGHT HOLDERS OR ANYONE DISTRIBUTING THE SOFTWARE BE LIABLE +FOR ANY DAMAGES OR OTHER LIABILITY, WHETHER IN CONTRACT, TORT OR OTHERWISE, +ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + + +CC-BY-4.0 +--------- +Creative Commons Attribution 4.0 International Public License + +By exercising the Licensed Rights (defined below), You accept and agree +to be bound by the terms and conditions of this Creative Commons +Attribution 4.0 International Public License ("Public +License"). To the extent this Public License may be interpreted as a +contract, You are granted the Licensed Rights in consideration of Your +acceptance of these terms and conditions, and the Licensor grants You +such rights in consideration of benefits the Licensor receives from +making the Licensed Material available under these terms and +conditions. + +Section 1 -- Definitions. + +a. Adapted Material means material subject to Copyright and Similar +Rights that is derived from or based upon the Licensed Material +and in which the Licensed Material is translated, altered, +arranged, transformed, or otherwise modified in a manner requiring +permission under the Copyright and Similar Rights held by the +Licensor. For purposes of this Public License, where the Licensed +Material is a musical work, performance, or sound recording, +Adapted Material is always produced where the Licensed Material is +synched in timed relation with a moving image. + +b. Adapter's License means the license You apply to Your Copyright +and Similar Rights in Your contributions to Adapted Material in +accordance with the terms and conditions of this Public License. + +c. Copyright and Similar Rights means copyright and/or similar rights +closely related to copyright including, without limitation, +performance, broadcast, sound recording, and Sui Generis Database +Rights, without regard to how the rights are labeled or +categorized. For purposes of this Public License, the rights +specified in Section 2(b)(1)-(2) are not Copyright and Similar +Rights. + +d. Effective Technological Measures means those measures that, in the +absence of proper authority, may not be circumvented under laws +fulfilling obligations under Article 11 of the WIPO Copyright +Treaty adopted on December 20, 1996, and/or similar international +agreements. + +e. Exceptions and Limitations means fair use, fair dealing, and/or +any other exception or limitation to Copyright and Similar Rights +that applies to Your use of the Licensed Material. + +f. Licensed Material means the artistic or literary work, database, +or other material to which the Licensor applied this Public +License. + +g. Licensed Rights means the rights granted to You subject to the +terms and conditions of this Public License, which are limited to +all Copyright and Similar Rights that apply to Your use of the +Licensed Material and that the Licensor has authority to license. + +h. Licensor means the individual(s) or entity(ies) granting rights +under this Public License. + +i. Share means to provide material to the public by any means or +process that requires permission under the Licensed Rights, such +as reproduction, public display, public performance, distribution, +dissemination, communication, or importation, and to make material +available to the public including in ways that members of the +public may access the material from a place and at a time +individually chosen by them. + +j. Sui Generis Database Rights means rights other than copyright +resulting from Directive 96/9/EC of the European Parliament and of +the Council of 11 March 1996 on the legal protection of databases, +as amended and/or succeeded, as well as other essentially +equivalent rights anywhere in the world. + +k. You means the individual or entity exercising the Licensed Rights +under this Public License. Your has a corresponding meaning. + +Section 2 -- Scope. + +a. License grant. + +1. Subject to the terms and conditions of this Public License, +the Licensor hereby grants You a worldwide, royalty-free, +non-sublicensable, non-exclusive, irrevocable license to +exercise the Licensed Rights in the Licensed Material to: + +a. reproduce and Share the Licensed Material, in whole or +in part; and + +b. produce, reproduce, and Share Adapted Material. + +2. Exceptions and Limitations. For the avoidance of doubt, where +Exceptions and Limitations apply to Your use, this Public +License does not apply, and You do not need to comply with +its terms and conditions. + +3. Term. The term of this Public License is specified in Section +6(a). + +4. Media and formats; technical modifications allowed. The +Licensor authorizes You to exercise the Licensed Rights in +all media and formats whether now known or hereafter created, +and to make technical modifications necessary to do so. The +Licensor waives and/or agrees not to assert any right or +authority to forbid You from making technical modifications +necessary to exercise the Licensed Rights, including +technical modifications necessary to circumvent Effective +Technological Measures. For purposes of this Public License, +simply making modifications authorized by this Section 2(a) +(4) never produces Adapted Material. + +5. Downstream recipients. + +a. Offer from the Licensor -- Licensed Material. Every +recipient of the Licensed Material automatically +receives an offer from the Licensor to exercise the +Licensed Rights under the terms and conditions of this +Public License. + +b. No downstream restrictions. You may not offer or impose +any additional or different terms or conditions on, or +apply any Effective Technological Measures to, the +Licensed Material if doing so restricts exercise of the +Licensed Rights by any recipient of the Licensed +Material. + +6. No endorsement. Nothing in this Public License constitutes or +may be construed as permission to assert or imply that You +are, or that Your use of the Licensed Material is, connected +with, or sponsored, endorsed, or granted official status by, +the Licensor or others designated to receive attribution as +provided in Section 3(a)(1)(A)(i). + +b. Other rights. + +1. Moral rights, such as the right of integrity, are not +licensed under this Public License, nor are publicity, +privacy, and/or other similar personality rights; however, to +the extent possible, the Licensor waives and/or agrees not to +assert any such rights held by the Licensor to the limited +extent necessary to allow You to exercise the Licensed +Rights, but not otherwise. + +2. Patent and trademark rights are not licensed under this +Public License. + +3. To the extent possible, the Licensor waives any right to +collect royalties from You for the exercise of the Licensed +Rights, whether directly or through a collecting society +under any voluntary or waivable statutory or compulsory +licensing scheme. In all other cases the Licensor expressly +reserves any right to collect such royalties. + +Section 3 -- License Conditions. + +Your exercise of the Licensed Rights is expressly made subject to the +following conditions. + +a. Attribution. + +1. If You Share the Licensed Material (including in modified +form), You must: + +a. retain the following if it is supplied by the Licensor +with the Licensed Material: + +i. identification of the creator(s) of the Licensed +Material and any others designated to receive +attribution, in any reasonable manner requested by +the Licensor (including by pseudonym if +designated); + +ii. a copyright notice; + +iii. a notice that refers to this Public License; + +iv. a notice that refers to the disclaimer of +warranties; + +v. a URI or hyperlink to the Licensed Material to the +extent reasonably practicable; + +b. indicate if You modified the Licensed Material and +retain an indication of any previous modifications; and + +c. indicate the Licensed Material is licensed under this +Public License, and include the text of, or the URI or +hyperlink to, this Public License. + +2. You may satisfy the conditions in Section 3(a)(1) in any +reasonable manner based on the medium, means, and context in +which You Share the Licensed Material. For example, it may be +reasonable to satisfy the conditions by providing a URI or +hyperlink to a resource that includes the required +information. + +3. If requested by the Licensor, You must remove any of the +information required by Section 3(a)(1)(A) to the extent +reasonably practicable. + +4. If You Share Adapted Material You produce, the Adapter's +License You apply must not prevent recipients of the Adapted +Material from complying with this Public License. + +Section 4 -- Sui Generis Database Rights. + +Where the Licensed Rights include Sui Generis Database Rights that +apply to Your use of the Licensed Material: + +a. for the avoidance of doubt, Section 2(a)(1) grants You the right +to extract, reuse, reproduce, and Share all or a substantial +portion of the contents of the database; + +b. if You include all or a substantial portion of the database +contents in a database in which You have Sui Generis Database +Rights, then the database in which You have Sui Generis Database +Rights (but not its individual contents) is Adapted Material; and + +c. You must comply with the conditions in Section 3(a) if You Share +all or a substantial portion of the contents of the database. + +For the avoidance of doubt, this Section 4 supplements and does not +replace Your obligations under this Public License where the Licensed +Rights include other Copyright and Similar Rights. + +Section 5 -- Disclaimer of Warranties and Limitation of Liability. + +a. UNLESS OTHERWISE SEPARATELY UNDERTAKEN BY THE LICENSOR, TO THE +EXTENT POSSIBLE, THE LICENSOR OFFERS THE LICENSED MATERIAL AS-IS +AND AS-AVAILABLE, AND MAKES NO REPRESENTATIONS OR WARRANTIES OF +ANY KIND CONCERNING THE LICENSED MATERIAL, WHETHER EXPRESS, +IMPLIED, STATUTORY, OR OTHER. THIS INCLUDES, WITHOUT LIMITATION, +WARRANTIES OF TITLE, MERCHANTABILITY, FITNESS FOR A PARTICULAR +PURPOSE, NON-INFRINGEMENT, ABSENCE OF LATENT OR OTHER DEFECTS, +ACCURACY, OR THE PRESENCE OR ABSENCE OF ERRORS, WHETHER OR NOT +KNOWN OR DISCOVERABLE. WHERE DISCLAIMERS OF WARRANTIES ARE NOT +ALLOWED IN FULL OR IN PART, THIS DISCLAIMER MAY NOT APPLY TO YOU. + +b. TO THE EXTENT POSSIBLE, IN NO EVENT WILL THE LICENSOR BE LIABLE +TO YOU ON ANY LEGAL THEORY (INCLUDING, WITHOUT LIMITATION, +NEGLIGENCE) OR OTHERWISE FOR ANY DIRECT, SPECIAL, INDIRECT, +INCIDENTAL, CONSEQUENTIAL, PUNITIVE, EXEMPLARY, OR OTHER LOSSES, +COSTS, EXPENSES, OR DAMAGES ARISING OUT OF THIS PUBLIC LICENSE OR +USE OF THE LICENSED MATERIAL, EVEN IF THE LICENSOR HAS BEEN +ADVISED OF THE POSSIBILITY OF SUCH LOSSES, COSTS, EXPENSES, OR +DAMAGES. WHERE A LIMITATION OF LIABILITY IS NOT ALLOWED IN FULL OR +IN PART, THIS LIMITATION MAY NOT APPLY TO YOU. + +c. The disclaimer of warranties and limitation of liability provided +above shall be interpreted in a manner that, to the extent +possible, most closely approximates an absolute disclaimer and +waiver of all liability. + +Section 6 -- Term and Termination. + +a. This Public License applies for the term of the Copyright and +Similar Rights licensed here. However, if You fail to comply with +this Public License, then Your rights under this Public License +terminate automatically. + +b. Where Your right to use the Licensed Material has terminated under +Section 6(a), it reinstates: + +1. automatically as of the date the violation is cured, provided +it is cured within 30 days of Your discovery of the +violation; or + +2. upon express reinstatement by the Licensor. + +For the avoidance of doubt, this Section 6(b) does not affect any +right the Licensor may have to seek remedies for Your violations +of this Public License. + +c. For the avoidance of doubt, the Licensor may also offer the +Licensed Material under separate terms or conditions or stop +distributing the Licensed Material at any time; however, doing so +will not terminate this Public License. + +d. Sections 1, 5, 6, 7, and 8 survive termination of this Public +License. + +Section 7 -- Other Terms and Conditions. + +a. The Licensor shall not be bound by any additional or different +terms or conditions communicated by You unless expressly agreed. + +b. Any arrangements, understandings, or agreements regarding the +Licensed Material not stated herein are separate from and +independent of the terms and conditions of this Public License. + +Section 8 -- Interpretation. + +a. For the avoidance of doubt, this Public License does not, and +shall not be interpreted to, reduce, limit, restrict, or impose +conditions on any use of the Licensed Material that could lawfully +be made without permission under this Public License. + +b. To the extent possible, if any provision of this Public License is +deemed unenforceable, it shall be automatically reformed to the +minimum extent necessary to make it enforceable. If the provision +cannot be reformed, it shall be severed from this Public License +without affecting the enforceability of the remaining terms and +conditions. + +c. No term or condition of this Public License will be waived and no +failure to comply consented to unless expressly agreed to by the +Licensor. + +d. Nothing in this Public License constitutes or may be interpreted +as a limitation upon, or waiver of, any privileges and immunities +that apply to the Licensor or You, including from the legal +processes of any jurisdiction or authority. + + +CC0-1.0 +------- +CC0 1.0 Universal + +Statement of Purpose + +The laws of most jurisdictions throughout the world automatically confer +exclusive Copyright and Related Rights (defined below) upon the creator and +subsequent owner(s) (each and all, an "owner") of an original work of +authorship and/or a database (each, a "Work"). + +Certain owners wish to permanently relinquish those rights to a Work for the +purpose of contributing to a commons of creative, cultural and scientific +works ("Commons") that the public can reliably and without fear of later +claims of infringement build upon, modify, incorporate in other works, reuse +and redistribute as freely as possible in any form whatsoever and for any +purposes, including without limitation commercial purposes. These owners may +contribute to the Commons to promote the ideal of a free culture and the +further production of creative, cultural and scientific works, or to gain +reputation or greater distribution for their Work in part through the use and +efforts of others. + +For these and/or other purposes and motivations, and without any expectation +of additional consideration or compensation, the person associating CC0 with a +Work (the "Affirmer"), to the extent that he or she is an owner of Copyright +and Related Rights in the Work, voluntarily elects to apply CC0 to the Work +and publicly distribute the Work under its terms, with knowledge of his or her +Copyright and Related Rights in the Work and the meaning and intended legal +effect of CC0 on those rights. + +1. Copyright and Related Rights. A Work made available under CC0 may be +protected by copyright and related or neighboring rights ("Copyright and +Related Rights"). Copyright and Related Rights include, but are not limited +to, the following: + +i. the right to reproduce, adapt, distribute, perform, display, communicate, +and translate a Work; + +ii. moral rights retained by the original author(s) and/or performer(s); + +iii. publicity and privacy rights pertaining to a person's image or likeness +depicted in a Work; + +iv. rights protecting against unfair competition in regards to a Work, +subject to the limitations in paragraph 4(a), below; + +v. rights protecting the extraction, dissemination, use and reuse of data in +a Work; + +vi. database rights (such as those arising under Directive 96/9/EC of the +European Parliament and of the Council of 11 March 1996 on the legal +protection of databases, and under any national implementation thereof, +including any amended or successor version of such directive); and + +vii. other similar, equivalent or corresponding rights throughout the world +based on applicable law or treaty, and any national implementations thereof. + +2. Waiver. To the greatest extent permitted by, but not in contravention of, +applicable law, Affirmer hereby overtly, fully, permanently, irrevocably and +unconditionally waives, abandons, and surrenders all of Affirmer's Copyright +and Related Rights and associated claims and causes of action, whether now +known or unknown (including existing as well as future claims and causes of +action), in the Work (i) in all territories worldwide, (ii) for the maximum +duration provided by applicable law or treaty (including future time +extensions), (iii) in any current or future medium and for any number of +copies, and (iv) for any purpose whatsoever, including without limitation +commercial, advertising or promotional purposes (the "Waiver"). Affirmer makes +the Waiver for the benefit of each member of the public at large and to the +detriment of Affirmer's heirs and successors, fully intending that such Waiver +shall not be subject to revocation, rescission, cancellation, termination, or +any other legal or equitable action to disrupt the quiet enjoyment of the Work +by the public as contemplated by Affirmer's express Statement of Purpose. + +3. Public License Fallback. Should any part of the Waiver for any reason be +judged legally invalid or ineffective under applicable law, then the Waiver +shall be preserved to the maximum extent permitted taking into account +Affirmer's express Statement of Purpose. In addition, to the extent the Waiver +is so judged Affirmer hereby grants to each affected person a royalty-free, +non transferable, non sublicensable, non exclusive, irrevocable and +unconditional license to exercise Affirmer's Copyright and Related Rights in +the Work (i) in all territories worldwide, (ii) for the maximum duration +provided by applicable law or treaty (including future time extensions), (iii) +in any current or future medium and for any number of copies, and (iv) for any +purpose whatsoever, including without limitation commercial, advertising or +promotional purposes (the "License"). The License shall be deemed effective as +of the date CC0 was applied by Affirmer to the Work. Should any part of the +License for any reason be judged legally invalid or ineffective under +applicable law, such partial invalidity or ineffectiveness shall not +invalidate the remainder of the License, and in such case Affirmer hereby +affirms that he or she will not (i) exercise any of his or her remaining +Copyright and Related Rights in the Work or (ii) assert any associated claims +and causes of action with respect to the Work, in either case contrary to +Affirmer's express Statement of Purpose. + +4. Limitations and Disclaimers. + +a. No trademark or patent rights held by Affirmer are waived, abandoned, +surrendered, licensed or otherwise affected by this document. + +b. Affirmer offers the Work as-is and makes no representations or warranties +of any kind concerning the Work, express, implied, statutory or otherwise, +including without limitation warranties of title, merchantability, fitness +for a particular purpose, non infringement, or the absence of latent or +other defects, accuracy, or the present or absence of errors, whether or not +discoverable, all to the greatest extent permissible under applicable law. + +c. Affirmer disclaims responsibility for clearing rights of other persons +that may apply to the Work or any use thereof, including without limitation +any person's Copyright and Related Rights in the Work. Further, Affirmer +disclaims responsibility for obtaining any necessary consents, permissions +or other rights required for any use of the Work. + +d. Affirmer understands and acknowledges that Creative Commons is not a +party to this document and has no duty or obligation with respect to this +CC0 or use of the Work. + + +Expat +----- +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + + +FTL +--- +The FreeType Project LICENSE +---------------------------- + +2006-Jan-27 + +Copyright 1996-2002, 2006 by +David Turner, Robert Wilhelm, and Werner Lemberg + + + +Introduction +============ + +The FreeType Project is distributed in several archive packages; +some of them may contain, in addition to the FreeType font engine, +various tools and contributions which rely on, or relate to, the +FreeType Project. + +This license applies to all files found in such packages, and +which do not fall under their own explicit license. The license +affects thus the FreeType font engine, the test programs, +documentation and makefiles, at the very least. + +This license was inspired by the BSD, Artistic, and IJG +(Independent JPEG Group) licenses, which all encourage inclusion +and use of free software in commercial and freeware products +alike. As a consequence, its main points are that: + +o We don't promise that this software works. However, we will be +interested in any kind of bug reports. (`as is' distribution) + +o You can use this software for whatever you want, in parts or +full form, without having to pay us. (`royalty-free' usage) + +o You may not pretend that you wrote this software. If you use +it, or only parts of it, in a program, you must acknowledge +somewhere in your documentation that you have used the +FreeType code. (`credits') + +We specifically permit and encourage the inclusion of this +software, with or without modifications, in commercial products. +We disclaim all warranties covering The FreeType Project and +assume no liability related to The FreeType Project. + + +Finally, many people asked us for a preferred form for a +credit/disclaimer to use in compliance with this license. We thus +encourage you to use the following text: + +""" +Portions of this software are copyright © The FreeType +Project (https://freetype.org). All rights reserved. +""" + +Please replace with the value from the FreeType version you +actually use. + + +Legal Terms +=========== + +0. Definitions +-------------- + +Throughout this license, the terms `package', `FreeType Project', +and `FreeType archive' refer to the set of files originally +distributed by the authors (David Turner, Robert Wilhelm, and +Werner Lemberg) as the `FreeType Project', be they named as alpha, +beta or final release. + +`You' refers to the licensee, or person using the project, where +`using' is a generic term including compiling the project's source +code as well as linking it to form a `program' or `executable'. +This program is referred to as `a program using the FreeType +engine'. + +This license applies to all files distributed in the original +FreeType Project, including all source code, binaries and +documentation, unless otherwise stated in the file in its +original, unmodified form as distributed in the original archive. +If you are unsure whether or not a particular file is covered by +this license, you must contact us to verify this. + +The FreeType Project is copyright (C) 1996-2000 by David Turner, +Robert Wilhelm, and Werner Lemberg. All rights reserved except as +specified below. + +1. No Warranty +-------------- + +THE FREETYPE PROJECT IS PROVIDED `AS IS' WITHOUT WARRANTY OF ANY +KIND, EITHER EXPRESS OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. IN NO EVENT WILL ANY OF THE AUTHORS OR COPYRIGHT HOLDERS +BE LIABLE FOR ANY DAMAGES CAUSED BY THE USE OR THE INABILITY TO +USE, OF THE FREETYPE PROJECT. + +2. Redistribution +----------------- + +This license grants a worldwide, royalty-free, perpetual and +irrevocable right and license to use, execute, perform, compile, +display, copy, create derivative works of, distribute and +sublicense the FreeType Project (in both source and object code +forms) and derivative works thereof for any purpose; and to +authorize others to exercise some or all of the rights granted +herein, subject to the following conditions: + +o Redistribution of source code must retain this license file +(`FTL.TXT') unaltered; any additions, deletions or changes to +the original files must be clearly indicated in accompanying +documentation. The copyright notices of the unaltered, +original files must be preserved in all copies of source +files. + +o Redistribution in binary form must provide a disclaimer that +states that the software is based in part of the work of the +FreeType Team, in the distribution documentation. We also +encourage you to put an URL to the FreeType web page in your +documentation, though this isn't mandatory. + +These conditions apply to any software derived from or based on +the FreeType Project, not just the unmodified files. If you use +our work, you must acknowledge us. However, no fee need be paid +to us. + +3. Advertising +-------------- + +Neither the FreeType authors and contributors nor you shall use +the name of the other for commercial, advertising, or promotional +purposes without specific prior written permission. + +We suggest, but do not require, that you use one or more of the +following phrases to refer to this software in your documentation +or advertising materials: `FreeType Project', `FreeType Engine', +`FreeType library', or `FreeType Distribution'. + +As you have not signed this license, you are not required to +accept it. However, as the FreeType Project is copyrighted +material, only this license, or another one contracted with the +authors, grants you the right to use, distribute, and modify it. +Therefore, by using, distributing, or modifying the FreeType +Project, you indicate that you understand and accept all the terms +of this license. + +4. Contacts +----------- + +There are two mailing lists related to FreeType: + +o freetype@nongnu.org + +Discusses general use and applications of FreeType, as well as +future and wanted additions to the library and distribution. +If you are looking for support, start in this list if you +haven't found anything to help you in the documentation. + +o freetype-devel@nongnu.org + +Discusses bugs, as well as engine internals, design issues, +specific licenses, porting, etc. + +Our home page can be found at + +https://freetype.org + + +HarfBuzz +-------- +HarfBuzz is licensed under the so-called "Old MIT" license. Details follow. +For parts of HarfBuzz that are licensed under different licenses see individual +files names COPYING in subdirectories where applicable. + +Copyright (C) 2010,2011,2012,2013,2014,2015,2016,2017,2018,2019,2020 Google, Inc. +Copyright (C) 2018,2019,2020 Ebrahim Byagowi +Copyright (C) 2019,2020 Facebook, Inc. +Copyright (C) 2012 Mozilla Foundation +Copyright (C) 2011 Codethink Limited +Copyright (C) 2008,2010 Nokia Corporation and/or its subsidiary(-ies) +Copyright (C) 2009 Keith Stribley +Copyright (C) 2009 Martin Hosken and SIL International +Copyright (C) 2007 Chris Wilson +Copyright (C) 2005,2006,2020,2021 Behdad Esfahbod +Copyright (C) 2005 David Turner +Copyright (C) 2004,2007,2008,2009,2010 Red Hat, Inc. +Copyright (C) 1998-2004 David Turner and Werner Lemberg + +For full copyright notices consult the individual files in the package. + + +Permission is hereby granted, without written agreement and without +license or royalty fees, to use, copy, modify, and distribute this +software and its documentation for any purpose, provided that the +above copyright notice and the following two paragraphs appear in +all copies of this software. + +IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE TO ANY PARTY FOR +DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES +ARISING OUT OF THE USE OF THIS SOFTWARE AND ITS DOCUMENTATION, EVEN +IF THE COPYRIGHT HOLDER HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH +DAMAGE. + +THE COPYRIGHT HOLDER SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING, +BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS FOR A PARTICULAR PURPOSE. THE SOFTWARE PROVIDED HEREUNDER IS +ON AN "AS IS" BASIS, AND THE COPYRIGHT HOLDER HAS NO OBLIGATION TO +PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. + + +IJG +--- +The authors make NO WARRANTY or representation, either express or implied, +with respect to this software, its quality, accuracy, merchantability, or +fitness for a particular purpose. This software is provided "AS IS", and you, +its user, assume the entire risk as to its quality and accuracy. + +This software is copyright (C) 1991-2020, Thomas G. Lane, Guido Vollbeding. +All Rights Reserved except as specified below. + +Permission is hereby granted to use, copy, modify, and distribute this +software (or portions thereof) for any purpose, without fee, subject to these +conditions: +(1) If any part of the source code for this software is distributed, then this +README file must be included, with this copyright and no-warranty notice +unaltered; and any additions, deletions, or changes to the original files +must be clearly indicated in accompanying documentation. +(2) If only executable code is distributed, then the accompanying +documentation must state that "this software is based in part on the work of +the Independent JPEG Group". +(3) Permission for use of this software is granted only if the user accepts +full responsibility for any undesirable consequences; the authors accept +NO LIABILITY for damages of any kind. + +These conditions apply to any software derived from or based on the IJG code, +not just to the unmodified library. If you use our work, you ought to +acknowledge us. + +Permission is NOT granted for the use of any IJG author's name or company name +in advertising or publicity relating to this software or products derived from +it. This software may be referred to only as "the Independent JPEG Group's +software". + +We specifically permit and encourage the use of this software as the basis of +commercial products, provided that all warranty or liability claims are +assumed by the product vendor. + + +MIT-0 +----- +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + + +MPL-2.0 +------- +Mozilla Public License Version 2.0 +================================== + +1. Definitions +-------------- + +1.1. "Contributor" +means each individual or legal entity that creates, contributes to +the creation of, or owns Covered Software. + +1.2. "Contributor Version" +means the combination of the Contributions of others (if any) used +by a Contributor and that particular Contributor's Contribution. + +1.3. "Contribution" +means Covered Software of a particular Contributor. + +1.4. "Covered Software" +means Source Code Form to which the initial Contributor has attached +the notice in Exhibit A, the Executable Form of such Source Code +Form, and Modifications of such Source Code Form, in each case +including portions thereof. + +1.5. "Incompatible With Secondary Licenses" +means + +(a) that the initial Contributor has attached the notice described +in Exhibit B to the Covered Software; or + +(b) that the Covered Software was made available under the terms of +version 1.1 or earlier of the License, but not also under the +terms of a Secondary License. + +1.6. "Executable Form" +means any form of the work other than Source Code Form. + +1.7. "Larger Work" +means a work that combines Covered Software with other material, in +a separate file or files, that is not Covered Software. + +1.8. "License" +means this document. + +1.9. "Licensable" +means having the right to grant, to the maximum extent possible, +whether at the time of the initial grant or subsequently, any and +all of the rights conveyed by this License. + +1.10. "Modifications" +means any of the following: + +(a) any file in Source Code Form that results from an addition to, +deletion from, or modification of the contents of Covered +Software; or + +(b) any new file in Source Code Form that contains any Covered +Software. + +1.11. "Patent Claims" of a Contributor +means any patent claim(s), including without limitation, method, +process, and apparatus claims, in any patent Licensable by such +Contributor that would be infringed, but for the grant of the +License, by the making, using, selling, offering for sale, having +made, import, or transfer of either its Contributions or its +Contributor Version. + +1.12. "Secondary License" +means either the GNU General Public License, Version 2.0, the GNU +Lesser General Public License, Version 2.1, the GNU Affero General +Public License, Version 3.0, or any later versions of those +licenses. + +1.13. "Source Code Form" +means the form of the work preferred for making modifications. + +1.14. "You" (or "Your") +means an individual or a legal entity exercising rights under this +License. For legal entities, "You" includes any entity that +controls, is controlled by, or is under common control with You. For +purposes of this definition, "control" means (a) the power, direct +or indirect, to cause the direction or management of such entity, +whether by contract or otherwise, or (b) ownership of more than +fifty percent (50%) of the outstanding shares or beneficial +ownership of such entity. + +2. License Grants and Conditions +-------------------------------- + +2.1. Grants + +Each Contributor hereby grants You a world-wide, royalty-free, +non-exclusive license: + +(a) under intellectual property rights (other than patent or trademark) +Licensable by such Contributor to use, reproduce, make available, +modify, display, perform, distribute, and otherwise exploit its +Contributions, either on an unmodified basis, with Modifications, or +as part of a Larger Work; and + +(b) under Patent Claims of such Contributor to make, use, sell, offer +for sale, have made, import, and otherwise transfer either its +Contributions or its Contributor Version. + +2.2. Effective Date + +The licenses granted in Section 2.1 with respect to any Contribution +become effective for each Contribution on the date the Contributor first +distributes such Contribution. + +2.3. Limitations on Grant Scope + +The licenses granted in this Section 2 are the only rights granted under +this License. No additional rights or licenses will be implied from the +distribution or licensing of Covered Software under this License. +Notwithstanding Section 2.1(b) above, no patent license is granted by a +Contributor: + +(a) for any code that a Contributor has removed from Covered Software; +or + +(b) for infringements caused by: (i) Your and any other third party's +modifications of Covered Software, or (ii) the combination of its +Contributions with other software (except as part of its Contributor +Version); or + +(c) under Patent Claims infringed by Covered Software in the absence of +its Contributions. + +This License does not grant any rights in the trademarks, service marks, +or logos of any Contributor (except as may be necessary to comply with +the notice requirements in Section 3.4). + +2.4. Subsequent Licenses + +No Contributor makes additional grants as a result of Your choice to +distribute the Covered Software under a subsequent version of this +License (see Section 10.2) or under the terms of a Secondary License (if +permitted under the terms of Section 3.3). + +2.5. Representation + +Each Contributor represents that the Contributor believes its +Contributions are its original creation(s) or it has sufficient rights +to grant the rights to its Contributions conveyed by this License. + +2.6. Fair Use + +This License is not intended to limit any rights You have under +applicable copyright doctrines of fair use, fair dealing, or other +equivalents. + +2.7. Conditions + +Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted +in Section 2.1. + +3. Responsibilities +------------------- + +3.1. Distribution of Source Form + +All distribution of Covered Software in Source Code Form, including any +Modifications that You create or to which You contribute, must be under +the terms of this License. You must inform recipients that the Source +Code Form of the Covered Software is governed by the terms of this +License, and how they can obtain a copy of this License. You may not +attempt to alter or restrict the recipients' rights in the Source Code +Form. + +3.2. Distribution of Executable Form + +If You distribute Covered Software in Executable Form then: + +(a) such Covered Software must also be made available in Source Code +Form, as described in Section 3.1, and You must inform recipients of +the Executable Form how they can obtain a copy of such Source Code +Form by reasonable means in a timely manner, at a charge no more +than the cost of distribution to the recipient; and + +(b) You may distribute such Executable Form under the terms of this +License, or sublicense it under different terms, provided that the +license for the Executable Form does not attempt to limit or alter +the recipients' rights in the Source Code Form under this License. + +3.3. Distribution of a Larger Work + +You may create and distribute a Larger Work under terms of Your choice, +provided that You also comply with the requirements of this License for +the Covered Software. If the Larger Work is a combination of Covered +Software with a work governed by one or more Secondary Licenses, and the +Covered Software is not Incompatible With Secondary Licenses, this +License permits You to additionally distribute such Covered Software +under the terms of such Secondary License(s), so that the recipient of +the Larger Work may, at their option, further distribute the Covered +Software under the terms of either this License or such Secondary +License(s). + +3.4. Notices + +You may not remove or alter the substance of any license notices +(including copyright notices, patent notices, disclaimers of warranty, +or limitations of liability) contained within the Source Code Form of +the Covered Software, except that You may alter any license notices to +the extent required to remedy known factual inaccuracies. + +3.5. Application of Additional Terms + +You may choose to offer, and to charge a fee for, warranty, support, +indemnity or liability obligations to one or more recipients of Covered +Software. However, You may do so only on Your own behalf, and not on +behalf of any Contributor. You must make it absolutely clear that any +such warranty, support, indemnity, or liability obligation is offered by +You alone, and You hereby agree to indemnify every Contributor for any +liability incurred by such Contributor as a result of warranty, support, +indemnity or liability terms You offer. You may include additional +disclaimers of warranty and limitations of liability specific to any +jurisdiction. + +4. Inability to Comply Due to Statute or Regulation +--------------------------------------------------- + +If it is impossible for You to comply with any of the terms of this +License with respect to some or all of the Covered Software due to +statute, judicial order, or regulation then You must: (a) comply with +the terms of this License to the maximum extent possible; and (b) +describe the limitations and the code they affect. Such description must +be placed in a text file included with all distributions of the Covered +Software under this License. Except to the extent prohibited by statute +or regulation, such description must be sufficiently detailed for a +recipient of ordinary skill to be able to understand it. + +5. Termination +-------------- + +5.1. The rights granted under this License will terminate automatically +if You fail to comply with any of its terms. However, if You become +compliant, then the rights granted under this License from a particular +Contributor are reinstated (a) provisionally, unless and until such +Contributor explicitly and finally terminates Your grants, and (b) on an +ongoing basis, if such Contributor fails to notify You of the +non-compliance by some reasonable means prior to 60 days after You have +come back into compliance. Moreover, Your grants from a particular +Contributor are reinstated on an ongoing basis if such Contributor +notifies You of the non-compliance by some reasonable means, this is the +first time You have received notice of non-compliance with this License +from such Contributor, and You become compliant prior to 30 days after +Your receipt of the notice. + +5.2. If You initiate litigation against any entity by asserting a patent +infringement claim (excluding declaratory judgment actions, +counter-claims, and cross-claims) alleging that a Contributor Version +directly or indirectly infringes any patent, then the rights granted to +You by any and all Contributors for the Covered Software under Section +2.1 of this License shall terminate. + +5.3. In the event of termination under Sections 5.1 or 5.2 above, all +end user license agreements (excluding distributors and resellers) which +have been validly granted by You or Your distributors under this License +prior to termination shall survive termination. + +************************************************************************ +* * +* 6. Disclaimer of Warranty * +* ------------------------- * +* * +* Covered Software is provided under this License on an "as is" * +* basis, without warranty of any kind, either expressed, implied, or * +* statutory, including, without limitation, warranties that the * +* Covered Software is free of defects, merchantable, fit for a * +* particular purpose or non-infringing. The entire risk as to the * +* quality and performance of the Covered Software is with You. * +* Should any Covered Software prove defective in any respect, You * +* (not any Contributor) assume the cost of any necessary servicing, * +* repair, or correction. This disclaimer of warranty constitutes an * +* essential part of this License. No use of any Covered Software is * +* authorized under this License except under this disclaimer. * +* * +************************************************************************ + +************************************************************************ +* * +* 7. Limitation of Liability * +* -------------------------- * +* * +* Under no circumstances and under no legal theory, whether tort * +* (including negligence), contract, or otherwise, shall any * +* Contributor, or anyone who distributes Covered Software as * +* permitted above, be liable to You for any direct, indirect, * +* special, incidental, or consequential damages of any character * +* including, without limitation, damages for lost profits, loss of * +* goodwill, work stoppage, computer failure or malfunction, or any * +* and all other commercial damages or losses, even if such party * +* shall have been informed of the possibility of such damages. This * +* limitation of liability shall not apply to liability for death or * +* personal injury resulting from such party's negligence to the * +* extent applicable law prohibits such limitation. Some * +* jurisdictions do not allow the exclusion or limitation of * +* incidental or consequential damages, so this exclusion and * +* limitation may not apply to You. * +* * +************************************************************************ + +8. Litigation +------------- + +Any litigation relating to this License may be brought only in the +courts of a jurisdiction where the defendant maintains its principal +place of business and such litigation shall be governed by laws of that +jurisdiction, without reference to its conflict-of-law provisions. +Nothing in this Section shall prevent a party's ability to bring +cross-claims or counter-claims. + +9. Miscellaneous +---------------- + +This License represents the complete agreement concerning the subject +matter hereof. If any provision of this License is held to be +unenforceable, such provision shall be reformed only to the extent +necessary to make it enforceable. Any law or regulation which provides +that the language of a contract shall be construed against the drafter +shall not be used to construe this License against a Contributor. + +10. Versions of the License +--------------------------- + +10.1. New Versions + +Mozilla Foundation is the license steward. Except as provided in Section +10.3, no one other than the license steward has the right to modify or +publish new versions of this License. Each version will be given a +distinguishing version number. + +10.2. Effect of New Versions + +You may distribute the Covered Software under the terms of the version +of the License under which You originally received the Covered Software, +or under the terms of any subsequent version published by the license +steward. + +10.3. Modified Versions + +If you create software not governed by this License, and you want to +create a new license for such software, you may create and use a +modified version of this License if you rename the license and remove +any references to the name of the license steward (except to note that +such modified license differs from this License). + +10.4. Distributing Source Code Form that is Incompatible With Secondary +Licenses + +If You choose to distribute Source Code Form that is Incompatible With +Secondary Licenses under the terms of this version of the License, the +notice described in Exhibit B of this License must be attached. + +Exhibit A - Source Code Form License Notice +------------------------------------------- + +This Source Code Form is subject to the terms of the Mozilla Public +License, v. 2.0. If a copy of the MPL was not distributed with this +file, You can obtain one at https://mozilla.org/MPL/2.0/. + +If it is not possible or desirable to put the notice in a particular +file, then You may include the notice in a location (such as a LICENSE +file in a relevant directory) where a recipient would be likely to look +for such a notice. + +You may add additional accurate notices of copyright ownership. + +Exhibit B - "Incompatible With Secondary Licenses" Notice +--------------------------------------------------------- + +This Source Code Form is "Incompatible With Secondary Licenses", as +defined by the Mozilla Public License, v. 2.0. + + +OFL-1.1 +------- +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE. + + +Unicode +------- +COPYRIGHT AND PERMISSION NOTICE (ICU 58 and later) + +Copyright (C) 1991-2020 Unicode, Inc. All rights reserved. +Distributed under the Terms of Use in https://www.unicode.org/copyright.html. + +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Unicode data files and any associated documentation +(the "Data Files") or Unicode software and any associated documentation +(the "Software") to deal in the Data Files or Software +without restriction, including without limitation the rights to use, +copy, modify, merge, publish, distribute, and/or sell copies of +the Data Files or Software, and to permit persons to whom the Data Files +or Software are furnished to do so, provided that either +(a) this copyright and permission notice appear with all copies +of the Data Files or Software, or +(b) this copyright and permission notice appear in associated +Documentation. + +THE DATA FILES AND SOFTWARE ARE PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE +WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT OF THIRD PARTY RIGHTS. +IN NO EVENT SHALL THE COPYRIGHT HOLDER OR HOLDERS INCLUDED IN THIS +NOTICE BE LIABLE FOR ANY CLAIM, OR ANY SPECIAL INDIRECT OR CONSEQUENTIAL +DAMAGES, OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, +DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER +TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +PERFORMANCE OF THE DATA FILES OR SOFTWARE. + +Except as contained in this notice, the name of a copyright holder +shall not be used in advertising or otherwise to promote the sale, +use or other dealings in these Data Files or Software without prior +written authorization of the copyright holder. + + +Unlicense +--------- +This is free and unencumbered software released into the public domain. + +Anyone is free to copy, modify, publish, use, compile, sell, or +distribute this software, either in source code form or as a compiled +binary, for any purpose, commercial or non-commercial, and by any +means. + +In jurisdictions that recognize copyright laws, the author or authors +of this software dedicate any and all copyright interest in the +software to the public domain. We make this dedication for the benefit +of the public at large and to the detriment of our heirs and +successors. We intend this dedication to be an overt act of +relinquishment in perpetuity of all present and future rights to this +software under copyright law. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR +OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, +ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +OTHER DEALINGS IN THE SOFTWARE. + +For more information, please refer to + + +WOL +--- +The Wide Open License (WOL) + +Permission to use, copy, modify, distribute and sell this software and its +documentation for any purpose is hereby granted without fee, provided that +the above copyright notice and this license appear in all source copies. +THIS SOFTWARE IS PROVIDED "AS IS" WITHOUT EXPRESS OR IMPLIED WARRANTY OF +ANY KIND. See https://dspguru.com/wide-open-license/ for more information. + + +X11 +--- +Permission to use, copy, modify, distribute, and sell this +software and its documentation for any purpose is hereby granted +without fee, provided that\n the above copyright notice appear in +all copies and that both that copyright notice and this permission +notice appear in supporting documentation, and that the name of +the copyright holders not be used in advertising or publicity +pertaining to distribution of the software without specific, +written prior permission. The copyright holders make no +representations about the suitability of this software for any +purpose. It is provided "as is" without express or implied +warranty. + +THE COPYRIGHT HOLDERS DISCLAIM ALL WARRANTIES WITH REGARD TO THIS +SOFTWARE, INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS, IN NO EVENT SHALL THE COPYRIGHT HOLDERS BE LIABLE FOR ANY +SPECIAL, INDIRECT OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN +AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, +ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF +THIS SOFTWARE. + + +Zlib +---- +This software is provided 'as-is', without any express or implied +warranty. In no event will the authors be held liable for any damages +arising from the use of this software. + +Permission is granted to anyone to use this software for any purpose, +including commercial applications, and to alter it and redistribute it +freely, subject to the following restrictions: + +1. The origin of this software must not be misrepresented; you must not +claim that you wrote the original software. If you use this software +in a product, an acknowledgment in the product documentation would be +appreciated but is not required. +2. Altered source versions must be plainly marked as such, and must not be +misrepresented as being the original software. +3. This notice may not be removed or altered from any source distribution. + + +glslang +------- +Here, glslang proper means core GLSL parsing, HLSL parsing, and SPIR-V code +generation. Glslang proper requires use of a number of licenses, one that covers +preprocessing and others that covers non-preprocessing. + +Bison was removed long ago. You can build glslang from the source grammar, +using tools of your choice, without using bison or any bison files. + +Other parts, outside of glslang proper, include: + +- gl_types.h, only needed for OpenGL-like reflection, and can be left out of +a parse and codegen project. See it for its license. + +- update_glslang_sources.py, which is not part of the project proper and does +not need to be used. + +- the SPIR-V "remapper", which is optional, but has the same license as +glslang proper + +- Google tests and SPIR-V tools, and anything in the external subdirectory +are external and optional; see them for their respective licenses. + +-------------------------------------------------------------------------------- + +The core of glslang-proper, minus the preprocessor is licenced as follows: + +-------------------------------------------------------------------------------- +3-Clause BSD License +-------------------------------------------------------------------------------- + +Copyright (C) 2015-2018 Google, Inc. +Copyright (C) + +All rights reserved. + +See: . + +-------------------------------------------------------------------------------- +2-Clause BSD License +-------------------------------------------------------------------------------- + +Copyright 2020 The Khronos Group Inc + +See: . + +-------------------------------------------------------------------------------- +The MIT License +-------------------------------------------------------------------------------- + +Copyright 2020 The Khronos Group Inc + +See: . + +-------------------------------------------------------------------------------- +APACHE LICENSE, VERSION 2.0 +-------------------------------------------------------------------------------- + +See: . + +-------------------------------------------------------------------------------- +GPL 3 with special bison exception +-------------------------------------------------------------------------------- + +Bison implementation for Yacc-like parsers in C + +Copyright (C) 1984, 1989-1990, 2000-2015 Free Software Foundation, Inc. + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . + +As a special exception, you may create a larger work that contains +part or all of the Bison parser skeleton and distribute that work +under terms of your choice, so long as that work isn't itself a +parser generator using the skeleton or a modified version thereof +as a parser skeleton. Alternatively, if you modify or redistribute +the parser skeleton itself, you may (at your option) remove this +special exception, which will cause the skeleton and the resulting +Bison output files to be licensed under the GNU General Public +License without this special exception. + +This special exception was added by the Free Software Foundation in +version 2.2 of Bison. + +-------------------------------------------------------------------------------- +================================================================================ +-------------------------------------------------------------------------------- + +The preprocessor has the core licenses stated above, plus an additional licence: + +Copyright (c) 2002, NVIDIA Corporation. + +NVIDIA Corporation("NVIDIA") supplies this software to you in +consideration of your agreement to the following terms, and your use, +installation, modification or redistribution of this NVIDIA software +constitutes acceptance of these terms. If you do not agree with these +terms, please do not use, install, modify or redistribute this NVIDIA +software. + +In consideration of your agreement to abide by the following terms, and +subject to these terms, NVIDIA grants you a personal, non-exclusive +license, under NVIDIA's copyrights in this original NVIDIA software (the +"NVIDIA Software"), to use, reproduce, modify and redistribute the +NVIDIA Software, with or without modifications, in source and/or binary +forms; provided that if you redistribute the NVIDIA Software, you must +retain the copyright notice of NVIDIA, this notice and the following +text and disclaimers in all such redistributions of the NVIDIA Software. +Neither the name, trademarks, service marks nor logos of NVIDIA +Corporation may be used to endorse or promote products derived from the +NVIDIA Software without specific prior written permission from NVIDIA. +Except as expressly stated in this notice, no other rights or licenses +express or implied, are granted by NVIDIA herein, including but not +limited to any patent rights that may be infringed by your derivative +works or by other works in which the NVIDIA Software may be +incorporated. No hardware is licensed hereunder. + +THE NVIDIA SOFTWARE IS BEING PROVIDED ON AN "AS IS" BASIS, WITHOUT +WARRANTIES OR CONDITIONS OF ANY KIND, EITHER EXPRESS OR IMPLIED, +INCLUDING WITHOUT LIMITATION, WARRANTIES OR CONDITIONS OF TITLE, +NON-INFRINGEMENT, MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, OR +ITS USE AND OPERATION EITHER ALONE OR IN COMBINATION WITH OTHER +PRODUCTS. + +IN NO EVENT SHALL NVIDIA BE LIABLE FOR ANY SPECIAL, INDIRECT, +INCIDENTAL, EXEMPLARY, CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED +TO, LOST PROFITS; PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF +USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) OR ARISING IN ANY WAY +OUT OF THE USE, REPRODUCTION, MODIFICATION AND/OR DISTRIBUTION OF THE +NVIDIA SOFTWARE, HOWEVER CAUSED AND WHETHER UNDER THEORY OF CONTRACT, +TORT (INCLUDING NEGLIGENCE), STRICT LIABILITY OR OTHERWISE, EVEN IF +NVIDIA HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + diff --git a/licenses/HYPERSPACE-LICENSE.md b/licenses/HYPERSPACE-LICENSE.md new file mode 100644 index 0000000..f6785a2 --- /dev/null +++ b/licenses/HYPERSPACE-LICENSE.md @@ -0,0 +1,74 @@ +## Hyperspace +FTL Hyperspace is licensed under a [Creative Commons Attribution-ShareAlike 4.0 International](http://creativecommons.org/licenses/by-sa/4.0/) license + +## Other Licenses +### ZHL +All files of ZHL must retain attribution to [Kilburn (@FixItVinh)](https://twitter.com/fixitvinh) if used. +ZHL files are: +- [`src/zhl/zhl.cpp`](src/zhl/zhl.cpp) +- [`src/zhl/zhl.h`](src/zhl/zhl.h) +- [`src/zhl/zhl_private.h`](src/zhl/zhl_private.h) +- [`src/zhl/zhl_internal.h`](src/zhl/zhl_internal.h) +- [`src/zhl/SigScan.cpp`](src/zhl/SigScan.cpp) +- [`src/zhl/SigScan.h`](src/zhl/SigScan.h) +- [`libzhlgen/lua/parsefuncs.lua`](libzhlgen/lua/parsefuncs.lua) +- [`libzhlgen/lua/cparser.lua`](libzhlgen/lua/cparser.lua) +- [`libzhlgen/test/IDA Formatting.txt`]() + +Further contributions to ZHL done by the hyperspace team remain under CC-BY-SA 4.0 as all other parts of Hyperspace. + +### RapidXML +MIT License +``` +Copyright (c) 2006, 2007 Marcin Kalicinski + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +of the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL +THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +IN THE SOFTWARE. +``` + +### Discord RPC +MIT License +``` +Copyright 2017 Discord, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +of the Software, and to permit persons to whom the Software is furnished to do +so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +``` + +### HDE32 & HDE64 +See LICENSE file in [`third_party/detours/hde32`](third_party/detours/hde32/LICENSE) and [`third_party/detours/hde64`](third_party/detours/hde64/LICENSE) folder + +### Mologie Detours +See License at top of [`third_party/detours/detours.h`](third_party/detours/detours.h) (But it's also MIT License) + +Further contributions to Mologie Detours by the Hyperspace team remain under CC-BY-SA 4.0 and not under its original MIT License diff --git a/licenses/NUMPY-LICENSE.txt b/licenses/NUMPY-LICENSE.txt new file mode 100644 index 0000000..3ccffba --- /dev/null +++ b/licenses/NUMPY-LICENSE.txt @@ -0,0 +1,950 @@ +Copyright (c) 2005-2025, NumPy Developers. +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + + * Neither the name of the NumPy Developers nor the names of any + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +---- + +The NumPy repository and source distributions bundle several libraries that are +compatibly licensed. We list these here. + +Name: lapack-lite +Files: numpy/linalg/lapack_lite/* +License: BSD-3-Clause + For details, see numpy/linalg/lapack_lite/LICENSE.txt + +Name: dragon4 +Files: numpy/_core/src/multiarray/dragon4.c +License: MIT + For license text, see numpy/_core/src/multiarray/dragon4.c + +Name: libdivide +Files: numpy/_core/include/numpy/libdivide/* +License: Zlib + For license text, see numpy/_core/include/numpy/libdivide/LICENSE.txt + + +Note that the following files are vendored in the repository and sdist but not +installed in built numpy packages: + +Name: Meson +Files: vendored-meson/meson/* +License: Apache 2.0 + For license text, see vendored-meson/meson/COPYING + +Name: spin +Files: .spin/cmds.py +License: BSD-3 + For license text, see .spin/LICENSE + +Name: tempita +Files: numpy/_build_utils/tempita/* +License: MIT + For details, see numpy/_build_utils/tempita/LICENCE.txt + +---- + +This binary distribution of NumPy also bundles the following software: + + +Name: OpenBLAS +Files: numpy.libs\libscipy_openblas*.dll +Description: bundled as a dynamically linked library +Availability: https://github.com/OpenMathLib/OpenBLAS/ +License: BSD-3-Clause + Copyright (c) 2011-2014, The OpenBLAS Project + All rights reserved. + + Redistribution and use in source and binary forms, with or without + modification, are permitted provided that the following conditions are + met: + + 1. Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + 2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in + the documentation and/or other materials provided with the + distribution. + 3. Neither the name of the OpenBLAS project nor the names of + its contributors may be used to endorse or promote products + derived from this software without specific prior written + permission. + + THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE + LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE + USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + + +Name: LAPACK +Files: numpy.libs\libscipy_openblas*.dll +Description: bundled in OpenBLAS +Availability: https://github.com/OpenMathLib/OpenBLAS/ +License: BSD-3-Clause-Open-MPI + Copyright (c) 1992-2013 The University of Tennessee and The University + of Tennessee Research Foundation. All rights + reserved. + Copyright (c) 2000-2013 The University of California Berkeley. All + rights reserved. + Copyright (c) 2006-2013 The University of Colorado Denver. All rights + reserved. + + $COPYRIGHT$ + + Additional copyrights may follow + + $HEADER$ + + Redistribution and use in source and binary forms, with or without + modification, are permitted provided that the following conditions are + met: + + - Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + - Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer listed + in this license in the documentation and/or other materials + provided with the distribution. + + - Neither the name of the copyright holders nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + + The copyright holders provide no reassurances that the source code + provided does not infringe any patent, copyright, or any other + intellectual property rights of third parties. The copyright holders + disclaim any liability to any recipient for claims brought against + recipient by any third party for infringement of that parties + intellectual property rights. + + THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + + +Name: GCC runtime library +Files: numpy.libs\libscipy_openblas*.dll +Description: statically linked to files compiled with gcc +Availability: https://gcc.gnu.org/git/?p=gcc.git;a=tree;f=libgfortran +License: GPL-3.0-or-later WITH GCC-exception-3.1 + Copyright (C) 2002-2017 Free Software Foundation, Inc. + + Libgfortran is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation; either version 3, or (at your option) + any later version. + + Libgfortran is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + Under Section 7 of GPL version 3, you are granted additional + permissions described in the GCC Runtime Library Exception, version + 3.1, as published by the Free Software Foundation. + + You should have received a copy of the GNU General Public License and + a copy of the GCC Runtime Library Exception along with this program; + see the files COPYING3 and COPYING.RUNTIME respectively. If not, see + . + +---- + +Full text of license texts referred to above follows (that they are +listed below does not necessarily imply the conditions apply to the +present binary release): + +---- + +GCC RUNTIME LIBRARY EXCEPTION + +Version 3.1, 31 March 2009 + +Copyright (C) 2009 Free Software Foundation, Inc. + +Everyone is permitted to copy and distribute verbatim copies of this +license document, but changing it is not allowed. + +This GCC Runtime Library Exception ("Exception") is an additional +permission under section 7 of the GNU General Public License, version +3 ("GPLv3"). It applies to a given file (the "Runtime Library") that +bears a notice placed by the copyright holder of the file stating that +the file is governed by GPLv3 along with this Exception. + +When you use GCC to compile a program, GCC may combine portions of +certain GCC header files and runtime libraries with the compiled +program. The purpose of this Exception is to allow compilation of +non-GPL (including proprietary) programs to use, in this way, the +header files and runtime libraries covered by this Exception. + +0. Definitions. + +A file is an "Independent Module" if it either requires the Runtime +Library for execution after a Compilation Process, or makes use of an +interface provided by the Runtime Library, but is not otherwise based +on the Runtime Library. + +"GCC" means a version of the GNU Compiler Collection, with or without +modifications, governed by version 3 (or a specified later version) of +the GNU General Public License (GPL) with the option of using any +subsequent versions published by the FSF. + +"GPL-compatible Software" is software whose conditions of propagation, +modification and use would permit combination with GCC in accord with +the license of GCC. + +"Target Code" refers to output from any compiler for a real or virtual +target processor architecture, in executable form or suitable for +input to an assembler, loader, linker and/or execution +phase. Notwithstanding that, Target Code does not include data in any +format that is used as a compiler intermediate representation, or used +for producing a compiler intermediate representation. + +The "Compilation Process" transforms code entirely represented in +non-intermediate languages designed for human-written code, and/or in +Java Virtual Machine byte code, into Target Code. Thus, for example, +use of source code generators and preprocessors need not be considered +part of the Compilation Process, since the Compilation Process can be +understood as starting with the output of the generators or +preprocessors. + +A Compilation Process is "Eligible" if it is done using GCC, alone or +with other GPL-compatible software, or if it is done without using any +work based on GCC. For example, using non-GPL-compatible Software to +optimize any GCC intermediate representations would not qualify as an +Eligible Compilation Process. + +1. Grant of Additional Permission. + +You have permission to propagate a work of Target Code formed by +combining the Runtime Library with Independent Modules, even if such +propagation would otherwise violate the terms of GPLv3, provided that +all Target Code was generated by Eligible Compilation Processes. You +may then convey such a combination under terms of your choice, +consistent with the licensing of the Independent Modules. + +2. No Weakening of GCC Copyleft. + +The availability of this Exception does not imply any general +presumption that third-party software is unaffected by the copyleft +requirements of the license of GCC. + +---- + + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. + diff --git a/licenses/PILLOW-LICENSE.txt b/licenses/PILLOW-LICENSE.txt new file mode 100644 index 0000000..fd5fb1f --- /dev/null +++ b/licenses/PILLOW-LICENSE.txt @@ -0,0 +1,1617 @@ +The Python Imaging Library (PIL) is + + Copyright © 1997-2011 by Secret Labs AB + Copyright © 1995-2011 by Fredrik Lundh and contributors + +Pillow is the friendly PIL fork. It is + + Copyright © 2010 by Jeffrey 'Alex' Clark and contributors + +Like PIL, Pillow is licensed under the open source MIT-CMU License: + +By obtaining, using, and/or copying this software and/or its associated +documentation, you agree that you have read, understood, and will comply +with the following terms and conditions: + +Permission to use, copy, modify and distribute this software and its +documentation for any purpose and without fee is hereby granted, +provided that the above copyright notice appears in all copies, and that +both that copyright notice and this permission notice appear in supporting +documentation, and that the name of Secret Labs AB or the author not be +used in advertising or publicity pertaining to distribution of the software +without specific, written prior permission. + +SECRET LABS AB AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS +SOFTWARE, INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. +IN NO EVENT SHALL SECRET LABS AB OR THE AUTHOR BE LIABLE FOR ANY SPECIAL, +INDIRECT OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE +OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +PERFORMANCE OF THIS SOFTWARE. + +===== brotli-1.2.0 ===== + +Copyright (c) 2009, 2010, 2013-2016 by the Brotli Authors. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + +===== freetype-2.14.3 ===== + +FREETYPE LICENSES +----------------- + +The FreeType 2 font engine is copyrighted work and cannot be used +legally without a software license. In order to make this project +usable to a vast majority of developers, we distribute it under two +mutually exclusive open-source licenses. + +This means that *you* must choose *one* of the two licenses described +below, then obey all its terms and conditions when using FreeType 2 in +any of your projects or products. + + - The FreeType License, found in the file `docs/FTL.TXT`, which is + similar to the original BSD license *with* an advertising clause + that forces you to explicitly cite the FreeType project in your + product's documentation. All details are in the license file. + This license is suited to products which don't use the GNU General + Public License. + + Note that this license is compatible to the GNU General Public + License version 3, but not version 2. + + - The GNU General Public License version 2, found in + `docs/GPLv2.TXT` (any later version can be used also), for + programs which already use the GPL. Note that the FTL is + incompatible with GPLv2 due to its advertisement clause. + +The contributed BDF and PCF drivers come with a license similar to +that of the X Window System. It is compatible to the above two +licenses (see files `src/bdf/README` and `src/pcf/README`). The same +holds for the source code files `src/base/fthash.c` and +`include/freetype/internal/fthash.h`; they were part of the BDF driver +in earlier FreeType versions. + +The gzip module uses the zlib license (see `src/gzip/zlib.h`) which +too is compatible to the above two licenses. + +The files `src/autofit/ft-hb-ft.c`, `src/autofit/ft-hb-decls.h`, +`src/autofit/ft-hb-types.h`, and `src/autofit/hb-script-list.h` +contain code taken (almost) verbatim from the HarfBuzz library, which +uses the 'Old MIT' license compatible to the above two licenses. + +The MD5 checksum support (only used for debugging in development +builds) is in the public domain. + + +--- end of LICENSE.TXT --- + The FreeType Project LICENSE + ---------------------------- + + 2006-Jan-27 + + Copyright 1996-2002, 2006 by + David Turner, Robert Wilhelm, and Werner Lemberg + + + +Introduction +============ + + The FreeType Project is distributed in several archive packages; + some of them may contain, in addition to the FreeType font engine, + various tools and contributions which rely on, or relate to, the + FreeType Project. + + This license applies to all files found in such packages, and + which do not fall under their own explicit license. The license + affects thus the FreeType font engine, the test programs, + documentation and makefiles, at the very least. + + This license was inspired by the BSD, Artistic, and IJG + (Independent JPEG Group) licenses, which all encourage inclusion + and use of free software in commercial and freeware products + alike. As a consequence, its main points are that: + + o We don't promise that this software works. However, we will be + interested in any kind of bug reports. (`as is' distribution) + + o You can use this software for whatever you want, in parts or + full form, without having to pay us. (`royalty-free' usage) + + o You may not pretend that you wrote this software. If you use + it, or only parts of it, in a program, you must acknowledge + somewhere in your documentation that you have used the + FreeType code. (`credits') + + We specifically permit and encourage the inclusion of this + software, with or without modifications, in commercial products. + We disclaim all warranties covering The FreeType Project and + assume no liability related to The FreeType Project. + + + Finally, many people asked us for a preferred form for a + credit/disclaimer to use in compliance with this license. We thus + encourage you to use the following text: + + """ + Portions of this software are copyright © The FreeType + Project (https://freetype.org). All rights reserved. + """ + + Please replace with the value from the FreeType version you + actually use. + + +Legal Terms +=========== + +0. Definitions +-------------- + + Throughout this license, the terms `package', `FreeType Project', + and `FreeType archive' refer to the set of files originally + distributed by the authors (David Turner, Robert Wilhelm, and + Werner Lemberg) as the `FreeType Project', be they named as alpha, + beta or final release. + + `You' refers to the licensee, or person using the project, where + `using' is a generic term including compiling the project's source + code as well as linking it to form a `program' or `executable'. + This program is referred to as `a program using the FreeType + engine'. + + This license applies to all files distributed in the original + FreeType Project, including all source code, binaries and + documentation, unless otherwise stated in the file in its + original, unmodified form as distributed in the original archive. + If you are unsure whether or not a particular file is covered by + this license, you must contact us to verify this. + + The FreeType Project is copyright (C) 1996-2000 by David Turner, + Robert Wilhelm, and Werner Lemberg. All rights reserved except as + specified below. + +1. No Warranty +-------------- + + THE FREETYPE PROJECT IS PROVIDED `AS IS' WITHOUT WARRANTY OF ANY + KIND, EITHER EXPRESS OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, + WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR + PURPOSE. IN NO EVENT WILL ANY OF THE AUTHORS OR COPYRIGHT HOLDERS + BE LIABLE FOR ANY DAMAGES CAUSED BY THE USE OR THE INABILITY TO + USE, OF THE FREETYPE PROJECT. + +2. Redistribution +----------------- + + This license grants a worldwide, royalty-free, perpetual and + irrevocable right and license to use, execute, perform, compile, + display, copy, create derivative works of, distribute and + sublicense the FreeType Project (in both source and object code + forms) and derivative works thereof for any purpose; and to + authorize others to exercise some or all of the rights granted + herein, subject to the following conditions: + + o Redistribution of source code must retain this license file + (`FTL.TXT') unaltered; any additions, deletions or changes to + the original files must be clearly indicated in accompanying + documentation. The copyright notices of the unaltered, + original files must be preserved in all copies of source + files. + + o Redistribution in binary form must provide a disclaimer that + states that the software is based in part of the work of the + FreeType Team, in the distribution documentation. We also + encourage you to put an URL to the FreeType web page in your + documentation, though this isn't mandatory. + + These conditions apply to any software derived from or based on + the FreeType Project, not just the unmodified files. If you use + our work, you must acknowledge us. However, no fee need be paid + to us. + +3. Advertising +-------------- + + Neither the FreeType authors and contributors nor you shall use + the name of the other for commercial, advertising, or promotional + purposes without specific prior written permission. + + We suggest, but do not require, that you use one or more of the + following phrases to refer to this software in your documentation + or advertising materials: `FreeType Project', `FreeType Engine', + `FreeType library', or `FreeType Distribution'. + + As you have not signed this license, you are not required to + accept it. However, as the FreeType Project is copyrighted + material, only this license, or another one contracted with the + authors, grants you the right to use, distribute, and modify it. + Therefore, by using, distributing, or modifying the FreeType + Project, you indicate that you understand and accept all the terms + of this license. + +4. Contacts +----------- + + There are two mailing lists related to FreeType: + + o freetype@nongnu.org + + Discusses general use and applications of FreeType, as well as + future and wanted additions to the library and distribution. + If you are looking for support, start in this list if you + haven't found anything to help you in the documentation. + + o freetype-devel@nongnu.org + + Discusses bugs, as well as engine internals, design issues, + specific licenses, porting, etc. + + Our home page can be found at + + https://freetype.org + + +--- end of FTL.TXT --- + GNU GENERAL PUBLIC LICENSE + Version 2, June 1991 + + Copyright (C) 1989, 1991 Free Software Foundation, Inc. + 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The licenses for most software are designed to take away your +freedom to share and change it. By contrast, the GNU General Public +License is intended to guarantee your freedom to share and change free +software--to make sure the software is free for all its users. This +General Public License applies to most of the Free Software +Foundation's software and to any other program whose authors commit to +using it. (Some other Free Software Foundation software is covered by +the GNU Library General Public License instead.) You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +this service if you wish), that you receive source code or can get it +if you want it, that you can change the software or use pieces of it +in new free programs; and that you know you can do these things. + + To protect your rights, we need to make restrictions that forbid +anyone to deny you these rights or to ask you to surrender the rights. +These restrictions translate to certain responsibilities for you if you +distribute copies of the software, or if you modify it. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must give the recipients all the rights that +you have. You must make sure that they, too, receive or can get the +source code. And you must show them these terms so they know their +rights. + + We protect your rights with two steps: (1) copyright the software, and +(2) offer you this license which gives you legal permission to copy, +distribute and/or modify the software. + + Also, for each author's protection and ours, we want to make certain +that everyone understands that there is no warranty for this free +software. If the software is modified by someone else and passed on, we +want its recipients to know that what they have is not the original, so +that any problems introduced by others will not reflect on the original +authors' reputations. + + Finally, any free program is threatened constantly by software +patents. We wish to avoid the danger that redistributors of a free +program will individually obtain patent licenses, in effect making the +program proprietary. To prevent this, we have made it clear that any +patent must be licensed for everyone's free use or not licensed at all. + + The precise terms and conditions for copying, distribution and +modification follow. + + GNU GENERAL PUBLIC LICENSE + TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION + + 0. This License applies to any program or other work which contains +a notice placed by the copyright holder saying it may be distributed +under the terms of this General Public License. The "Program", below, +refers to any such program or work, and a "work based on the Program" +means either the Program or any derivative work under copyright law: +that is to say, a work containing the Program or a portion of it, +either verbatim or with modifications and/or translated into another +language. (Hereinafter, translation is included without limitation in +the term "modification".) Each licensee is addressed as "you". + +Activities other than copying, distribution and modification are not +covered by this License; they are outside its scope. The act of +running the Program is not restricted, and the output from the Program +is covered only if its contents constitute a work based on the +Program (independent of having been made by running the Program). +Whether that is true depends on what the Program does. + + 1. You may copy and distribute verbatim copies of the Program's +source code as you receive it, in any medium, provided that you +conspicuously and appropriately publish on each copy an appropriate +copyright notice and disclaimer of warranty; keep intact all the +notices that refer to this License and to the absence of any warranty; +and give any other recipients of the Program a copy of this License +along with the Program. + +You may charge a fee for the physical act of transferring a copy, and +you may at your option offer warranty protection in exchange for a fee. + + 2. You may modify your copy or copies of the Program or any portion +of it, thus forming a work based on the Program, and copy and +distribute such modifications or work under the terms of Section 1 +above, provided that you also meet all of these conditions: + + a) You must cause the modified files to carry prominent notices + stating that you changed the files and the date of any change. + + b) You must cause any work that you distribute or publish, that in + whole or in part contains or is derived from the Program or any + part thereof, to be licensed as a whole at no charge to all third + parties under the terms of this License. + + c) If the modified program normally reads commands interactively + when run, you must cause it, when started running for such + interactive use in the most ordinary way, to print or display an + announcement including an appropriate copyright notice and a + notice that there is no warranty (or else, saying that you provide + a warranty) and that users may redistribute the program under + these conditions, and telling the user how to view a copy of this + License. (Exception: if the Program itself is interactive but + does not normally print such an announcement, your work based on + the Program is not required to print an announcement.) + +These requirements apply to the modified work as a whole. If +identifiable sections of that work are not derived from the Program, +and can be reasonably considered independent and separate works in +themselves, then this License, and its terms, do not apply to those +sections when you distribute them as separate works. But when you +distribute the same sections as part of a whole which is a work based +on the Program, the distribution of the whole must be on the terms of +this License, whose permissions for other licensees extend to the +entire whole, and thus to each and every part regardless of who wrote it. + +Thus, it is not the intent of this section to claim rights or contest +your rights to work written entirely by you; rather, the intent is to +exercise the right to control the distribution of derivative or +collective works based on the Program. + +In addition, mere aggregation of another work not based on the Program +with the Program (or with a work based on the Program) on a volume of +a storage or distribution medium does not bring the other work under +the scope of this License. + + 3. You may copy and distribute the Program (or a work based on it, +under Section 2) in object code or executable form under the terms of +Sections 1 and 2 above provided that you also do one of the following: + + a) Accompany it with the complete corresponding machine-readable + source code, which must be distributed under the terms of Sections + 1 and 2 above on a medium customarily used for software interchange; or, + + b) Accompany it with a written offer, valid for at least three + years, to give any third party, for a charge no more than your + cost of physically performing source distribution, a complete + machine-readable copy of the corresponding source code, to be + distributed under the terms of Sections 1 and 2 above on a medium + customarily used for software interchange; or, + + c) Accompany it with the information you received as to the offer + to distribute corresponding source code. (This alternative is + allowed only for noncommercial distribution and only if you + received the program in object code or executable form with such + an offer, in accord with Subsection b above.) + +The source code for a work means the preferred form of the work for +making modifications to it. For an executable work, complete source +code means all the source code for all modules it contains, plus any +associated interface definition files, plus the scripts used to +control compilation and installation of the executable. However, as a +special exception, the source code distributed need not include +anything that is normally distributed (in either source or binary +form) with the major components (compiler, kernel, and so on) of the +operating system on which the executable runs, unless that component +itself accompanies the executable. + +If distribution of executable or object code is made by offering +access to copy from a designated place, then offering equivalent +access to copy the source code from the same place counts as +distribution of the source code, even though third parties are not +compelled to copy the source along with the object code. + + 4. You may not copy, modify, sublicense, or distribute the Program +except as expressly provided under this License. Any attempt +otherwise to copy, modify, sublicense or distribute the Program is +void, and will automatically terminate your rights under this License. +However, parties who have received copies, or rights, from you under +this License will not have their licenses terminated so long as such +parties remain in full compliance. + + 5. You are not required to accept this License, since you have not +signed it. However, nothing else grants you permission to modify or +distribute the Program or its derivative works. These actions are +prohibited by law if you do not accept this License. Therefore, by +modifying or distributing the Program (or any work based on the +Program), you indicate your acceptance of this License to do so, and +all its terms and conditions for copying, distributing or modifying +the Program or works based on it. + + 6. Each time you redistribute the Program (or any work based on the +Program), the recipient automatically receives a license from the +original licensor to copy, distribute or modify the Program subject to +these terms and conditions. You may not impose any further +restrictions on the recipients' exercise of the rights granted herein. +You are not responsible for enforcing compliance by third parties to +this License. + + 7. If, as a consequence of a court judgment or allegation of patent +infringement or for any other reason (not limited to patent issues), +conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot +distribute so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you +may not distribute the Program at all. For example, if a patent +license would not permit royalty-free redistribution of the Program by +all those who receive copies directly or indirectly through you, then +the only way you could satisfy both it and this License would be to +refrain entirely from distribution of the Program. + +If any portion of this section is held invalid or unenforceable under +any particular circumstance, the balance of the section is intended to +apply and the section as a whole is intended to apply in other +circumstances. + +It is not the purpose of this section to induce you to infringe any +patents or other property right claims or to contest validity of any +such claims; this section has the sole purpose of protecting the +integrity of the free software distribution system, which is +implemented by public license practices. Many people have made +generous contributions to the wide range of software distributed +through that system in reliance on consistent application of that +system; it is up to the author/donor to decide if he or she is willing +to distribute software through any other system and a licensee cannot +impose that choice. + +This section is intended to make thoroughly clear what is believed to +be a consequence of the rest of this License. + + 8. If the distribution and/or use of the Program is restricted in +certain countries either by patents or by copyrighted interfaces, the +original copyright holder who places the Program under this License +may add an explicit geographical distribution limitation excluding +those countries, so that distribution is permitted only in or among +countries not thus excluded. In such case, this License incorporates +the limitation as if written in the body of this License. + + 9. The Free Software Foundation may publish revised and/or new versions +of the General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + +Each version is given a distinguishing version number. If the Program +specifies a version number of this License which applies to it and "any +later version", you have the option of following the terms and conditions +either of that version or of any later version published by the Free +Software Foundation. If the Program does not specify a version number of +this License, you may choose any version ever published by the Free Software +Foundation. + + 10. If you wish to incorporate parts of the Program into other free +programs whose distribution conditions are different, write to the author +to ask for permission. For software which is copyrighted by the Free +Software Foundation, write to the Free Software Foundation; we sometimes +make exceptions for this. Our decision will be guided by the two goals +of preserving the free status of all derivatives of our free software and +of promoting the sharing and reuse of software generally. + + NO WARRANTY + + 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY +FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN +OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES +PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED +OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS +TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE +PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, +REPAIR OR CORRECTION. + + 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR +REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, +INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING +OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED +TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY +YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER +PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE +POSSIBILITY OF SUCH DAMAGES. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +convey the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation; either version 2 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program; if not, write to the Free Software + Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA + + +Also add information on how to contact you by electronic and paper mail. + +If the program is interactive, make it output a short notice like this +when it starts in an interactive mode: + + Gnomovision version 69, Copyright (C) year name of author + Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, the commands you use may +be called something other than `show w' and `show c'; they could even be +mouse-clicks or menu items--whatever suits your program. + +You should also get your employer (if you work as a programmer) or your +school, if any, to sign a "copyright disclaimer" for the program, if +necessary. Here is a sample; alter the names: + + Yoyodyne, Inc., hereby disclaims all copyright interest in the program + `Gnomovision' (which makes passes at compilers) written by James Hacker. + + , 1 April 1989 + Ty Coon, President of Vice + +This General Public License does not permit incorporating your program into +proprietary programs. If your program is a subroutine library, you may +consider it more useful to permit linking proprietary applications with the +library. If this is what you want to do, use the GNU Library General +Public License instead of this License. + +===== harfbuzz-14.2.1 ===== + +HarfBuzz is licensed under the so-called "Old MIT" license. Details follow. +For parts of HarfBuzz that are licensed under different licenses see individual +files names COPYING in subdirectories where applicable. + +Copyright © 2010-2022 Google, Inc. +Copyright © 2015-2020 Ebrahim Byagowi +Copyright © 2019,2020 Facebook, Inc. +Copyright © 2012,2015 Mozilla Foundation +Copyright © 2011 Codethink Limited +Copyright © 2008,2010 Nokia Corporation and/or its subsidiary(-ies) +Copyright © 2009 Keith Stribley +Copyright © 2011 Martin Hosken and SIL International +Copyright © 2007 Chris Wilson +Copyright © 2005,2006,2020,2021,2022,2023 Behdad Esfahbod +Copyright © 2004,2007,2008,2009,2010,2013,2021,2022,2023 Red Hat, Inc. +Copyright © 1998-2005 David Turner and Werner Lemberg +Copyright © 2016 Igalia S.L. +Copyright © 2022 Matthias Clasen +Copyright © 2018,2021 Khaled Hosny +Copyright © 2018,2019,2020 Adobe, Inc +Copyright © 2013-2015 Alexei Podtelezhnikov + +For full copyright notices consult the individual files in the package. + + +Permission is hereby granted, without written agreement and without +license or royalty fees, to use, copy, modify, and distribute this +software and its documentation for any purpose, provided that the +above copyright notice and the following two paragraphs appear in +all copies of this software. + +IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE TO ANY PARTY FOR +DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES +ARISING OUT OF THE USE OF THIS SOFTWARE AND ITS DOCUMENTATION, EVEN +IF THE COPYRIGHT HOLDER HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH +DAMAGE. + +THE COPYRIGHT HOLDER SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING, +BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS FOR A PARTICULAR PURPOSE. THE SOFTWARE PROVIDED HEREUNDER IS +ON AN "AS IS" BASIS, AND THE COPYRIGHT HOLDER HAS NO OBLIGATION TO +PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. + +===== lcms2-2.19.1 ===== + +MIT License + +Copyright (c) 2023 Marti Maria Saguer + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject +to the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +===== libavif-1.4.2 ===== + +Copyright 2019 Joe Drago. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this +list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, +this list of conditions and the following disclaimer in the documentation +and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +------------------------------------------------------------------------------ + +Files: src/obu.c + +Copyright © 2018-2019, VideoLAN and dav1d authors +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +------------------------------------------------------------------------------ + +Files: third_party/iccjpeg/* + +In plain English: + +1. We don't promise that this software works. (But if you find any bugs, + please let us know!) +2. You can use this software for whatever you want. You don't have to pay us. +3. You may not pretend that you wrote this software. If you use it in a + program, you must acknowledge somewhere in your documentation that + you've used the IJG code. + +In legalese: + +The authors make NO WARRANTY or representation, either express or implied, +with respect to this software, its quality, accuracy, merchantability, or +fitness for a particular purpose. This software is provided "AS IS", and you, +its user, assume the entire risk as to its quality and accuracy. + +This software is copyright (C) 1991-2013, Thomas G. Lane, Guido Vollbeding. +All Rights Reserved except as specified below. + +Permission is hereby granted to use, copy, modify, and distribute this +software (or portions thereof) for any purpose, without fee, subject to these +conditions: +(1) If any part of the source code for this software is distributed, then this +README file must be included, with this copyright and no-warranty notice +unaltered; and any additions, deletions, or changes to the original files +must be clearly indicated in accompanying documentation. +(2) If only executable code is distributed, then the accompanying +documentation must state that "this software is based in part on the work of +the Independent JPEG Group". +(3) Permission for use of this software is granted only if the user accepts +full responsibility for any undesirable consequences; the authors accept +NO LIABILITY for damages of any kind. + +These conditions apply to any software derived from or based on the IJG code, +not just to the unmodified library. If you use our work, you ought to +acknowledge us. + +Permission is NOT granted for the use of any IJG author's name or company name +in advertising or publicity relating to this software or products derived from +it. This software may be referred to only as "the Independent JPEG Group's +software". + +We specifically permit and encourage the use of this software as the basis of +commercial products, provided that all warranty or liability claims are +assumed by the product vendor. + + +The Unix configuration script "configure" was produced with GNU Autoconf. +It is copyright by the Free Software Foundation but is freely distributable. +The same holds for its supporting scripts (config.guess, config.sub, +ltmain.sh). Another support script, install-sh, is copyright by X Consortium +but is also freely distributable. + +The IJG distribution formerly included code to read and write GIF files. +To avoid entanglement with the Unisys LZW patent, GIF reading support has +been removed altogether, and the GIF writer has been simplified to produce +"uncompressed GIFs". This technique does not use the LZW algorithm; the +resulting GIF files are larger than usual, but are readable by all standard +GIF decoders. + +We are required to state that + "The Graphics Interchange Format(c) is the Copyright property of + CompuServe Incorporated. GIF(sm) is a Service Mark property of + CompuServe Incorporated." + +------------------------------------------------------------------------------ + +Files: contrib/gdk-pixbuf/* + +Copyright 2020 Emmanuel Gil Peyrot. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this +list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, +this list of conditions and the following disclaimer in the documentation +and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +------------------------------------------------------------------------------ + +Files: android_jni/gradlew* + + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +------------------------------------------------------------------------------ + +Files: third_party/libyuv/* + +Copyright 2011 The LibYuv Project Authors. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + * Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in + the documentation and/or other materials provided with the + distribution. + + * Neither the name of Google nor the names of its contributors may + be used to endorse or promote products derived from this software + without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +===== libjpeg-turbo-3.1.4.1 ===== + +LEGAL ISSUES +============ + +In plain English: + +1. We don't promise that this software works. (But if you find any bugs, + please let us know!) +2. You can use this software for whatever you want. You don't have to pay us. +3. You may not pretend that you wrote this software. If you use it in a + program, you must acknowledge somewhere in your documentation that + you've used the IJG code. + +In legalese: + +The authors make NO WARRANTY or representation, either express or implied, +with respect to this software, its quality, accuracy, merchantability, or +fitness for a particular purpose. This software is provided "AS IS", and you, +its user, assume the entire risk as to its quality and accuracy. + +This software is copyright (C) 1991-2020, Thomas G. Lane, Guido Vollbeding. +All Rights Reserved except as specified below. + +Permission is hereby granted to use, copy, modify, and distribute this +software (or portions thereof) for any purpose, without fee, subject to these +conditions: +(1) If any part of the source code for this software is distributed, then this +README file must be included, with this copyright and no-warranty notice +unaltered; and any additions, deletions, or changes to the original files +must be clearly indicated in accompanying documentation. +(2) If only executable code is distributed, then the accompanying +documentation must state that "this software is based in part on the work of +the Independent JPEG Group". +(3) Permission for use of this software is granted only if the user accepts +full responsibility for any undesirable consequences; the authors accept +NO LIABILITY for damages of any kind. + +These conditions apply to any software derived from or based on the IJG code, +not just to the unmodified library. If you use our work, you ought to +acknowledge us. + +Permission is NOT granted for the use of any IJG author's name or company name +in advertising or publicity relating to this software or products derived from +it. This software may be referred to only as "the Independent JPEG Group's +software". + +We specifically permit and encourage the use of this software as the basis of +commercial products, provided that all warranty or liability claims are +assumed by the product vendor. + +libjpeg-turbo Licenses +====================== + +libjpeg-turbo is covered by two compatible BSD-style open source licenses: + +- The IJG (Independent JPEG Group) License, which is listed in + [README.ijg](README.ijg) + + This license applies to the libjpeg API library and associated programs, + including any code inherited from libjpeg and any modifications to that + code. Note that the libjpeg-turbo SIMD source code bears the + [zlib License](https://opensource.org/licenses/Zlib), but in the context of + the overall libjpeg API library, the terms of the zlib License are subsumed + by the terms of the IJG License. + +- The Modified (3-clause) BSD License, which is listed below + + This license applies to the TurboJPEG API library and associated programs, as + well as the build system. Note that the TurboJPEG API library wraps the + libjpeg API library, so in the context of the overall TurboJPEG API library, + both the terms of the IJG License and the terms of the Modified (3-clause) + BSD License apply. + + +Complying with the libjpeg-turbo Licenses +========================================= + +This section provides a roll-up of the libjpeg-turbo licensing terms, to the +best of our understanding. This is not a license in and of itself. It is +intended solely for clarification. + +1. If you are distributing a modified version of the libjpeg-turbo source, + then: + + 1. You cannot alter or remove any existing copyright or license notices + from the source. + + **Origin** + - Clause 1 of the IJG License + - Clause 1 of the Modified BSD License + - Clauses 1 and 3 of the zlib License + + 2. You must add your own copyright notice to the header of each source + file you modified, so others can tell that you modified that file. (If + there is not an existing copyright header in that file, then you can + simply add a notice stating that you modified the file.) + + **Origin** + - Clause 1 of the IJG License + - Clause 2 of the zlib License + + 3. You must include the IJG README file, and you must not alter any of the + copyright or license text in that file. + + **Origin** + - Clause 1 of the IJG License + +2. If you are distributing only libjpeg-turbo binaries without the source, or + if you are distributing an application that statically links with + libjpeg-turbo, then: + + 1. Your product documentation must include a message stating: + + This software is based in part on the work of the Independent JPEG + Group. + + **Origin** + - Clause 2 of the IJG license + + 2. If your binary distribution includes or uses the TurboJPEG API, then + your product documentation must include the text of the Modified BSD + License (see below.) + + **Origin** + - Clause 2 of the Modified BSD License + +3. You cannot use the name of the IJG or The libjpeg-turbo Project or the + contributors thereof in advertising, publicity, etc. + + **Origin** + - IJG License + - Clause 3 of the Modified BSD License + +4. The IJG and The libjpeg-turbo Project do not warrant libjpeg-turbo to be + free of defects, nor do we accept any liability for undesirable + consequences resulting from your use of the software. + + **Origin** + - IJG License + - Modified BSD License + - zlib License + + +The Modified (3-clause) BSD License +=================================== + +Copyright (C) 2009-2026 D. R. Commander. All Rights Reserved.
+Copyright (C) 2015 Viktor Szathmáry. All Rights Reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +- Redistributions of source code must retain the above copyright notice, + this list of conditions and the following disclaimer. +- Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. +- Neither the name of the libjpeg-turbo Project nor the names of its + contributors may be used to endorse or promote products derived from this + software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS", +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE +LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +POSSIBILITY OF SUCH DAMAGE. + + +Why Two Licenses? +================= + +The zlib License could have been used instead of the Modified (3-clause) BSD +License, and since the IJG License effectively subsumes the distribution +conditions of the zlib License, this would have effectively placed +libjpeg-turbo binary distributions under the IJG License. However, the IJG +License specifically refers to the Independent JPEG Group and does not extend +attribution and endorsement protections to other entities. Thus, it was +desirable to choose a license that granted us the same protections for new code +that were granted to the IJG for code derived from their software. + +===== libpng-1.6.58 ===== + +COPYRIGHT NOTICE, DISCLAIMER, and LICENSE +========================================= + +PNG Reference Library License version 2 +--------------------------------------- + + * Copyright (c) 1995-2026 The PNG Reference Library Authors. + * Copyright (c) 2018-2026 Cosmin Truta. + * Copyright (c) 2000-2002, 2004, 2006-2018 Glenn Randers-Pehrson. + * Copyright (c) 1996-1997 Andreas Dilger. + * Copyright (c) 1995-1996 Guy Eric Schalnat, Group 42, Inc. + +The software is supplied "as is", without warranty of any kind, +express or implied, including, without limitation, the warranties +of merchantability, fitness for a particular purpose, title, and +non-infringement. In no event shall the Copyright owners, or +anyone distributing the software, be liable for any damages or +other liability, whether in contract, tort or otherwise, arising +from, out of, or in connection with the software, or the use or +other dealings in the software, even if advised of the possibility +of such damage. + +Permission is hereby granted to use, copy, modify, and distribute +this software, or portions hereof, for any purpose, without fee, +subject to the following restrictions: + + 1. The origin of this software must not be misrepresented; you + must not claim that you wrote the original software. If you + use this software in a product, an acknowledgment in the product + documentation would be appreciated, but is not required. + + 2. Altered source versions must be plainly marked as such, and must + not be misrepresented as being the original software. + + 3. This Copyright notice may not be removed or altered from any + source or altered source distribution. + + +PNG Reference Library License version 1 (for libpng 0.5 through 1.6.35) +----------------------------------------------------------------------- + +libpng versions 1.0.7, July 1, 2000, through 1.6.35, July 15, 2018 are +Copyright (c) 2000-2002, 2004, 2006-2018 Glenn Randers-Pehrson, are +derived from libpng-1.0.6, and are distributed according to the same +disclaimer and license as libpng-1.0.6 with the following individuals +added to the list of Contributing Authors: + + Simon-Pierre Cadieux + Eric S. Raymond + Mans Rullgard + Cosmin Truta + Gilles Vollant + James Yu + Mandar Sahastrabuddhe + Google Inc. + Vadim Barkov + +and with the following additions to the disclaimer: + + There is no warranty against interference with your enjoyment of + the library or against infringement. There is no warranty that our + efforts or the library will fulfill any of your particular purposes + or needs. This library is provided with all faults, and the entire + risk of satisfactory quality, performance, accuracy, and effort is + with the user. + +Some files in the "contrib" directory and some configure-generated +files that are distributed with libpng have other copyright owners, and +are released under other open source licenses. + +libpng versions 0.97, January 1998, through 1.0.6, March 20, 2000, are +Copyright (c) 1998-2000 Glenn Randers-Pehrson, are derived from +libpng-0.96, and are distributed according to the same disclaimer and +license as libpng-0.96, with the following individuals added to the +list of Contributing Authors: + + Tom Lane + Glenn Randers-Pehrson + Willem van Schaik + +libpng versions 0.89, June 1996, through 0.96, May 1997, are +Copyright (c) 1996-1997 Andreas Dilger, are derived from libpng-0.88, +and are distributed according to the same disclaimer and license as +libpng-0.88, with the following individuals added to the list of +Contributing Authors: + + John Bowler + Kevin Bracey + Sam Bushell + Magnus Holmgren + Greg Roelofs + Tom Tanner + +Some files in the "scripts" directory have other copyright owners, +but are released under this license. + +libpng versions 0.5, May 1995, through 0.88, January 1996, are +Copyright (c) 1995-1996 Guy Eric Schalnat, Group 42, Inc. + +For the purposes of this copyright and license, "Contributing Authors" +is defined as the following set of individuals: + + Andreas Dilger + Dave Martindale + Guy Eric Schalnat + Paul Schmidt + Tim Wegner + +The PNG Reference Library is supplied "AS IS". The Contributing +Authors and Group 42, Inc. disclaim all warranties, expressed or +implied, including, without limitation, the warranties of +merchantability and of fitness for any purpose. The Contributing +Authors and Group 42, Inc. assume no liability for direct, indirect, +incidental, special, exemplary, or consequential damages, which may +result from the use of the PNG Reference Library, even if advised of +the possibility of such damage. + +Permission is hereby granted to use, copy, modify, and distribute this +source code, or portions hereof, for any purpose, without fee, subject +to the following restrictions: + + 1. The origin of this source code must not be misrepresented. + + 2. Altered versions must be plainly marked as such and must not + be misrepresented as being the original source. + + 3. This Copyright notice may not be removed or altered from any + source or altered source distribution. + +The Contributing Authors and Group 42, Inc. specifically permit, +without fee, and encourage the use of this source code as a component +to supporting the PNG file format in commercial products. If you use +this source code in a product, acknowledgment is not required but would +be appreciated. + +===== libwebp-1.6.0 ===== + +Copyright (c) 2010, Google Inc. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + * Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in + the documentation and/or other materials provided with the + distribution. + + * Neither the name of Google nor the names of its contributors may + be used to endorse or promote products derived from this software + without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + + +===== openjpeg-2.5.4 ===== + +/* + * The copyright in this software is being made available under the 2-clauses + * BSD License, included below. This software may be subject to other third + * party and contributor rights, including patent rights, and no such rights + * are granted under this license. + * + * Copyright (c) 2002-2014, Universite catholique de Louvain (UCL), Belgium + * Copyright (c) 2002-2014, Professor Benoit Macq + * Copyright (c) 2003-2014, Antonin Descampe + * Copyright (c) 2003-2009, Francois-Olivier Devaux + * Copyright (c) 2005, Herve Drolon, FreeImage Team + * Copyright (c) 2002-2003, Yannick Verschueren + * Copyright (c) 2001-2003, David Janssens + * Copyright (c) 2011-2012, Centre National d'Etudes Spatiales (CNES), France + * Copyright (c) 2012, CS Systemes d'Information, France + * + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions + * are met: + * 1. Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * 2. Redistributions in binary form must reproduce the above copyright + * notice, this list of conditions and the following disclaimer in the + * documentation and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS `AS IS' + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE + * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR + * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + */ + +===== tiff-4.7.1 ===== + +# LibTIFF license + +Copyright © 1988-1997 Sam Leffler\ +Copyright © 1991-1997 Silicon Graphics, Inc. + +Permission to use, copy, modify, distribute, and sell this software and +its documentation for any purpose is hereby granted without fee, provided +that (i) the above copyright notices and this permission notice appear in +all copies of the software and related documentation, and (ii) the names of +Sam Leffler and Silicon Graphics may not be used in any advertising or +publicity relating to the software without the specific, prior written +permission of Sam Leffler and Silicon Graphics. + +THE SOFTWARE IS PROVIDED "AS-IS" AND WITHOUT WARRANTY OF ANY KIND, +EXPRESS, IMPLIED OR OTHERWISE, INCLUDING WITHOUT LIMITATION, ANY +WARRANTY OF MERCHANTABILITY OR FITNESS FOR A PARTICULAR PURPOSE. + +IN NO EVENT SHALL SAM LEFFLER OR SILICON GRAPHICS BE LIABLE FOR +ANY SPECIAL, INCIDENTAL, INDIRECT OR CONSEQUENTIAL DAMAGES OF ANY KIND, +OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, +WHETHER OR NOT ADVISED OF THE POSSIBILITY OF DAMAGE, AND ON ANY THEORY OF +LIABILITY, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE +OF THIS SOFTWARE. + +# Lempel-Ziv & Welch Compression (tif_lzw.c) license +The code of tif_lzw.c is derived from the compress program whose code is +derived from software contributed to Berkeley by James A. Woods, +derived from original work by Spencer Thomas and Joseph Orost. + +The original Berkeley copyright notice appears below in its entirety: + +Copyright (c) 1985, 1986 The Regents of the University of California. +All rights reserved. + +This code is derived from software contributed to Berkeley by +James A. Woods, derived from original work by Spencer Thomas +and Joseph Orost. + +Redistribution and use in source and binary forms are permitted +provided that the above copyright notice and this paragraph are +duplicated in all such forms and that any documentation, +advertising materials, and other materials related to such +distribution and use acknowledge that the software was developed +by the University of California, Berkeley. The name of the +University may not be used to endorse or promote products derived +from this software without specific prior written permission. +THIS SOFTWARE IS PROVIDED ``AS IS'' AND WITHOUT ANY EXPRESS OR +IMPLIED WARRANTIES, INCLUDING, WITHOUT LIMITATION, THE IMPLIED +WARRANTIES OF MERCHANTIBILITY AND FITNESS FOR A PARTICULAR PURPOSE. + +===== xz-5.8.3 ===== + + +XZ Utils Licensing +================== + + Different licenses apply to different files in this package. Here + is a summary of which licenses apply to which parts of this package: + + - liblzma is under the BSD Zero Clause License (0BSD). + + - The command line tools xz, xzdec, lzmadec, and lzmainfo are + under 0BSD except that, on systems that don't have a usable + getopt_long, GNU getopt_long is compiled and linked in from the + 'lib' directory. The getopt_long code is under GNU LGPLv2.1+. + + - The scripts to grep, diff, and view compressed files have been + adapted from GNU gzip. These scripts (xzgrep, xzdiff, xzless, + and xzmore) are under GNU GPLv2+. The man pages of the scripts + are under 0BSD; they aren't based on the man pages of GNU gzip. + + - Most of the XZ Utils specific documentation that is in + plain text files (like README, INSTALL, PACKAGERS, NEWS, + and ChangeLog) are under 0BSD unless stated otherwise in + the file itself. The files xz-file-format.txt and + lzma-file-format.xt are in the public domain but may + be distributed under the terms of 0BSD too. + + - Translated messages and man pages are under 0BSD except that + some old translations are in the public domain. + + - Test files and test code in the 'tests' directory, and + debugging utilities in the 'debug' directory are under + the BSD Zero Clause License (0BSD). + + - The GNU Autotools based build system contains files that are + under GNU GPLv2+, GNU GPLv3+, and a few permissive licenses. + These files don't affect the licensing of the binaries being + built. + + - The 'extra' directory contains files that are under various + free software licenses. These aren't built or installed as + part of XZ Utils. + + The following command may be helpful in finding per-file license + information. It works on xz.git and on a clean file tree extracted + from a release tarball. + + sh build-aux/license-check.sh -v + + For the files under the BSD Zero Clause License (0BSD), if + a copyright notice is needed, the following is sufficient: + + Copyright (C) The XZ Utils authors and contributors + + If you copy significant amounts of 0BSD-licensed code from XZ Utils + into your project, acknowledging this somewhere in your software is + polite (especially if it is proprietary, non-free software), but + it is not legally required by the license terms. Here is an example + of a good notice to put into "about box" or into documentation: + + This software includes code from XZ Utils . + + The following license texts are included in the following files: + - COPYING.0BSD: BSD Zero Clause License + - COPYING.LGPLv2.1: GNU Lesser General Public License version 2.1 + - COPYING.GPLv2: GNU General Public License version 2 + - COPYING.GPLv3: GNU General Public License version 3 + + If you have questions, don't hesitate to ask for more information. + The contact information is in the README file. + + +===== zlib-ng-2.3.3 ===== + +(C) 1995-2024 Jean-loup Gailly and Mark Adler + +This software is provided 'as-is', without any express or implied +warranty. In no event will the authors be held liable for any damages +arising from the use of this software. + +Permission is granted to anyone to use this software for any purpose, +including commercial applications, and to alter it and redistribute it +freely, subject to the following restrictions: + +1. The origin of this software must not be misrepresented; you must not + claim that you wrote the original software. If you use this software + in a product, an acknowledgment in the product documentation would be + appreciated but is not required. + +2. Altered source versions must be plainly marked as such, and must not be + misrepresented as being the original software. + +3. This notice may not be removed or altered from any source distribution. diff --git a/licenses/README.md b/licenses/README.md new file mode 100644 index 0000000..e44cdc7 --- /dev/null +++ b/licenses/README.md @@ -0,0 +1,20 @@ +# License-reference documents + +These files preserve upstream notices and record the external software inspected while preparing this source repository. The software implementations and FTL game content are not included. + +| File | Origin | +| --- | --- | +| [UNIVERSAL-MODDER-LICENSE.txt](UNIVERSAL-MODDER-LICENSE.txt) | Unchanged upstream MIT notice | +| [GODOT-LICENSE.txt](GODOT-LICENSE.txt) | `Engine.get_license_text()` from Godot 4.7.2 standard Windows engine | +| [GODOT_COPYRIGHT.txt](GODOT_COPYRIGHT.txt) | All returned Godot copyright groups and full license definitions from the same engine | +| [FRIDA-COPYING.txt](FRIDA-COPYING.txt) | Frida 17.9.0 Python wheel's `licenses/COPYING` | +| [CAPSTONE-LICENSE.txt](CAPSTONE-LICENSE.txt) | Capstone 5.0.9 Python distribution's `LICENSE.TXT` | +| [PILLOW-LICENSE.txt](PILLOW-LICENSE.txt) | Complete Pillow 12.3.0 installed distribution's license file, including bundled component notices | +| [NUMPY-LICENSE.txt](NUMPY-LICENSE.txt) | Complete NumPy 2.3.5 installed distribution's license file, including bundled component notices | +| [HYPERSPACE-LICENSE.md](HYPERSPACE-LICENSE.md) | Unchanged LICENSE.md from Hyperspace 1.23.2 source | +| [FTLMAN-LICENSE.txt](FTLMAN-LICENSE.txt) | Unchanged LICENSE from ftlman 0.7.4 source | +| [manifest.json](manifest.json) | Version, provenance and SHA-256 integrity records | + +Upstream copies retain their original content, including relative references. For links inside the Hyperspace document, consult the upstream revision linked in [THIRD_PARTY_NOTICES.md](../THIRD_PARTY_NOTICES.md). + +The root [LICENSE](../LICENSE) covers this project's source. [THIRD_PARTY_NOTICES.md](../THIRD_PARTY_NOTICES.md) explains external-component scope, unbundled prerequisites, ranged dependency versions and future compiled-release considerations. diff --git a/licenses/UNIVERSAL-MODDER-LICENSE.txt b/licenses/UNIVERSAL-MODDER-LICENSE.txt new file mode 100644 index 0000000..f59e7e8 --- /dev/null +++ b/licenses/UNIVERSAL-MODDER-LICENSE.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Rehan and universal-modder contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE +OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/licenses/manifest.json b/licenses/manifest.json new file mode 100644 index 0000000..0a0d296 --- /dev/null +++ b/licenses/manifest.json @@ -0,0 +1,89 @@ +{ + "scope": "License-reference documents; dependency implementations and FTL content are not bundled.", + "inspected_on": "2026-10-03", + "documents": [ + { + "file": "UNIVERSAL-MODDER-LICENSE.txt", + "component": "universal-modder", + "inspected_version": null, + "upstream_path": "LICENSE", + "source_url": "https://github.com/rehan-remade/universal-modder/blob/main/LICENSE", + "sha256": "db6cfa2ad2c44f73080440c866c4a3a078749d84ba40b4dfa27d50f18a525543", + "verbatim_copy": true + }, + { + "file": "FRIDA-COPYING.txt", + "component": "Frida Python wheel", + "inspected_version": "17.9.0", + "upstream_path": "frida-17.9.0.dist-info/licenses/COPYING", + "source_url": "https://frida.re/", + "sha256": "803b8b5aa4151030221b3c3f71a645da6241938421e49901444a79e5cca75fa8", + "verbatim_copy": true + }, + { + "file": "CAPSTONE-LICENSE.txt", + "component": "Capstone Python wheel", + "inspected_version": "5.0.9", + "upstream_path": "capstone-5.0.9.dist-info/LICENSE.TXT", + "source_url": "https://github.com/capstone-engine/capstone/blob/5.0.9/LICENSE.TXT", + "sha256": "404bd0cb0137ffb797258f844f53e5273f9b6d5781a1a359a2880411f49a4f30", + "verbatim_copy": true + }, + { + "file": "PILLOW-LICENSE.txt", + "component": "Pillow installed distribution", + "inspected_version": "12.3.0", + "upstream_path": "pillow-12.3.0.dist-info/licenses/LICENSE", + "source_url": "https://github.com/python-pillow/Pillow/blob/main/LICENSE", + "sha256": "4f7866a74802c6326f81faff59a56546b6aec2b10b91973e0e9308de95e79857", + "verbatim_copy": true + }, + { + "file": "NUMPY-LICENSE.txt", + "component": "NumPy installed distribution", + "inspected_version": "2.3.5", + "upstream_path": "numpy-2.3.5.dist-info/LICENSE.txt", + "source_url": "https://github.com/numpy/numpy/blob/v2.3.5/LICENSE.txt", + "sha256": "596b953e1dcbe829b32ae444387efa15003fc6abdeca8a2179f364a6364c286e", + "verbatim_copy": true + }, + { + "file": "HYPERSPACE-LICENSE.md", + "component": "FTL Hyperspace source", + "inspected_version": "1.23.2", + "upstream_path": "LICENSE.md", + "source_url": "https://github.com/FTL-Hyperspace/FTL-Hyperspace/blob/db570d728321a5a2c70add0988153373fd4ec08a/LICENSE.md", + "sha256": "2a8dcc1ec5d444e5e4514eaa0f5041a24630ddf3c0ed28307740a9a0efea543d", + "verbatim_copy": true + }, + { + "file": "FTLMAN-LICENSE.txt", + "component": "ftlman source", + "inspected_version": "0.7.4", + "upstream_path": "LICENSE", + "source_url": "https://github.com/afishhh/ftlman/blob/7ad94795f9eff7f4bf005ea40d9bb2cf4d57ec88/LICENSE", + "sha256": "edaef632cbb643e4e7a221717a6c441a4c1a7c918e6e4d56debc3d8739b233f6", + "verbatim_copy": true + }, + { + "file": "GODOT-LICENSE.txt", + "component": "Godot standard Windows engine", + "inspected_version": "4.7.2-stable (official)", + "build_hash": "ed1daf0bf001b61586d9930840f2f1394092c079", + "extraction_method": "Engine.get_license_text()", + "source_url": "https://docs.godotengine.org/en/stable/about/complying_with_licenses.html", + "sha256": "a0acc151e87517ee3411acce58ce99e17f636921b37c158f38f0b5acafd10da2", + "verbatim_copy": false + }, + { + "file": "GODOT_COPYRIGHT.txt", + "component": "Godot standard Windows engine", + "inspected_version": "4.7.2-stable (official)", + "build_hash": "ed1daf0bf001b61586d9930840f2f1394092c079", + "extraction_method": "Engine.get_copyright_info() + Engine.get_license_info()", + "source_url": "https://docs.godotengine.org/en/stable/about/complying_with_licenses.html", + "sha256": "b0bb6c35a100551b073bb56b6c0862d35d82d5c10f2631cfd168f20c8c5275b0", + "verbatim_copy": false + } + ] +} diff --git a/main.tscn b/main.tscn new file mode 100644 index 0000000..991da9f --- /dev/null +++ b/main.tscn @@ -0,0 +1,7 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://scripts/main.gd" id="1_main"] + +[node name="FTLTabletop" type="Node3D"] +script = ExtResource("1_main") + diff --git a/openxr_action_map.tres b/openxr_action_map.tres new file mode 100644 index 0000000..54feea0 --- /dev/null +++ b/openxr_action_map.tres @@ -0,0 +1,795 @@ +[gd_resource type="OpenXRActionMap" format=3] + +[sub_resource type="OpenXRAction" id="OpenXRAction_6ivru"] +resource_name = "trigger" +localized_name = "Trigger" +toplevel_paths = PackedStringArray("/user/hand/left", "/user/hand/right") + +[sub_resource type="OpenXRAction" id="OpenXRAction_vfhwq"] +resource_name = "trigger_click" +localized_name = "Trigger click" +action_type = 0 +toplevel_paths = PackedStringArray("/user/hand/left", "/user/hand/right") + +[sub_resource type="OpenXRAction" id="OpenXRAction_5w03k"] +resource_name = "trigger_touch" +localized_name = "Trigger touching" +action_type = 0 +toplevel_paths = PackedStringArray("/user/hand/left", "/user/hand/right") + +[sub_resource type="OpenXRAction" id="OpenXRAction_typ1r"] +resource_name = "grip" +localized_name = "Grip" +toplevel_paths = PackedStringArray("/user/hand/left", "/user/hand/right") + +[sub_resource type="OpenXRAction" id="OpenXRAction_clvbf"] +resource_name = "grip_click" +localized_name = "Grip click" +action_type = 0 +toplevel_paths = PackedStringArray("/user/hand/left", "/user/hand/right") + +[sub_resource type="OpenXRAction" id="OpenXRAction_5bppb"] +resource_name = "grip_force" +localized_name = "Grip force" +toplevel_paths = PackedStringArray("/user/hand/left", "/user/hand/right") + +[sub_resource type="OpenXRAction" id="OpenXRAction_3k6la"] +resource_name = "primary" +localized_name = "Primary joystick/thumbstick/trackpad" +action_type = 2 +toplevel_paths = PackedStringArray("/user/hand/left", "/user/hand/right") + +[sub_resource type="OpenXRAction" id="OpenXRAction_i8esw"] +resource_name = "primary_click" +localized_name = "Primary joystick/thumbstick/trackpad click" +action_type = 0 +toplevel_paths = PackedStringArray("/user/hand/left", "/user/hand/right") + +[sub_resource type="OpenXRAction" id="OpenXRAction_um1hv"] +resource_name = "primary_touch" +localized_name = "Primary joystick/thumbstick/trackpad touching" +action_type = 0 +toplevel_paths = PackedStringArray("/user/hand/left", "/user/hand/right") + +[sub_resource type="OpenXRAction" id="OpenXRAction_oqnsu"] +resource_name = "secondary" +localized_name = "Secondary joystick/thumbstick/trackpad" +action_type = 2 +toplevel_paths = PackedStringArray("/user/hand/left", "/user/hand/right") + +[sub_resource type="OpenXRAction" id="OpenXRAction_r5bl7"] +resource_name = "secondary_click" +localized_name = "Secondary joystick/thumbstick/trackpad click" +action_type = 0 +toplevel_paths = PackedStringArray("/user/hand/left", "/user/hand/right") + +[sub_resource type="OpenXRAction" id="OpenXRAction_ytptc"] +resource_name = "secondary_touch" +localized_name = "Secondary joystick/thumbstick/trackpad touching" +action_type = 0 +toplevel_paths = PackedStringArray("/user/hand/left", "/user/hand/right") + +[sub_resource type="OpenXRAction" id="OpenXRAction_iphn4"] +resource_name = "menu_button" +localized_name = "Menu button" +action_type = 0 +toplevel_paths = PackedStringArray("/user/hand/left", "/user/hand/right") + +[sub_resource type="OpenXRAction" id="OpenXRAction_3p2as"] +resource_name = "select_button" +localized_name = "Select button" +action_type = 0 +toplevel_paths = PackedStringArray("/user/hand/left", "/user/hand/right") + +[sub_resource type="OpenXRAction" id="OpenXRAction_wdehm"] +resource_name = "ax_button" +localized_name = "A/X button" +action_type = 0 +toplevel_paths = PackedStringArray("/user/hand/left", "/user/hand/right") + +[sub_resource type="OpenXRAction" id="OpenXRAction_clfly"] +resource_name = "ax_touch" +localized_name = "A/X touching" +action_type = 0 +toplevel_paths = PackedStringArray("/user/hand/left", "/user/hand/right") + +[sub_resource type="OpenXRAction" id="OpenXRAction_e1frq"] +resource_name = "by_button" +localized_name = "B/Y button" +action_type = 0 +toplevel_paths = PackedStringArray("/user/hand/left", "/user/hand/right") + +[sub_resource type="OpenXRAction" id="OpenXRAction_l7aq8"] +resource_name = "by_touch" +localized_name = "B/Y touching" +action_type = 0 +toplevel_paths = PackedStringArray("/user/hand/left", "/user/hand/right") + +[sub_resource type="OpenXRAction" id="OpenXRAction_oi0ij"] +resource_name = "default_pose" +localized_name = "Default pose" +action_type = 3 +toplevel_paths = PackedStringArray("/user/hand/left", "/user/hand/right", "/user/vive_tracker_htcx/role/left_foot", "/user/vive_tracker_htcx/role/right_foot", "/user/vive_tracker_htcx/role/left_shoulder", "/user/vive_tracker_htcx/role/right_shoulder", "/user/vive_tracker_htcx/role/left_elbow", "/user/vive_tracker_htcx/role/right_elbow", "/user/vive_tracker_htcx/role/left_knee", "/user/vive_tracker_htcx/role/right_knee", "/user/vive_tracker_htcx/role/waist", "/user/vive_tracker_htcx/role/chest", "/user/vive_tracker_htcx/role/camera", "/user/vive_tracker_htcx/role/keyboard", "/user/vive_tracker_htcx/role/left_wrist", "/user/vive_tracker_htcx/role/right_wrist", "/user/vive_tracker_htcx/role/left_ankle", "/user/vive_tracker_htcx/role/right_ankle", "/user/eyes_ext") + +[sub_resource type="OpenXRAction" id="OpenXRAction_m08eo"] +resource_name = "aim_pose" +localized_name = "Aim pose" +action_type = 3 +toplevel_paths = PackedStringArray("/user/hand/left", "/user/hand/right") + +[sub_resource type="OpenXRAction" id="OpenXRAction_c4j1d"] +resource_name = "grip_pose" +localized_name = "Grip pose" +action_type = 3 +toplevel_paths = PackedStringArray("/user/hand/left", "/user/hand/right") + +[sub_resource type="OpenXRAction" id="OpenXRAction_sopde"] +resource_name = "palm_pose" +localized_name = "Palm pose" +action_type = 3 +toplevel_paths = PackedStringArray("/user/hand/left", "/user/hand/right") + +[sub_resource type="OpenXRAction" id="OpenXRAction_sow2k"] +resource_name = "haptic" +localized_name = "Haptic" +action_type = 4 +toplevel_paths = PackedStringArray("/user/hand/left", "/user/hand/right", "/user/vive_tracker_htcx/role/left_foot", "/user/vive_tracker_htcx/role/right_foot", "/user/vive_tracker_htcx/role/left_shoulder", "/user/vive_tracker_htcx/role/right_shoulder", "/user/vive_tracker_htcx/role/left_elbow", "/user/vive_tracker_htcx/role/right_elbow", "/user/vive_tracker_htcx/role/left_knee", "/user/vive_tracker_htcx/role/right_knee", "/user/vive_tracker_htcx/role/waist", "/user/vive_tracker_htcx/role/chest", "/user/vive_tracker_htcx/role/camera", "/user/vive_tracker_htcx/role/keyboard", "/user/vive_tracker_htcx/role/left_wrist", "/user/vive_tracker_htcx/role/right_wrist", "/user/vive_tracker_htcx/role/left_ankle", "/user/vive_tracker_htcx/role/right_ankle") + +[sub_resource type="OpenXRAction" id="Ergo_page_left"] +resource_name = "page_left" +localized_name = "page_left" +action_type = 0 +toplevel_paths = PackedStringArray("/user/hand/left") + +[sub_resource type="OpenXRAction" id="Ergo_page_right"] +resource_name = "page_right" +localized_name = "page_right" +action_type = 0 +toplevel_paths = PackedStringArray("/user/hand/left") + +[sub_resource type="OpenXRAction" id="Ergo_wheel_button"] +resource_name = "wheel_button" +localized_name = "wheel_button" +action_type = 0 +toplevel_paths = PackedStringArray("/user/hand/right") + +[sub_resource type="OpenXRAction" id="Ergo_tactical_button"] +resource_name = "tactical_button" +localized_name = "Toggle tactical screen" +action_type = 0 +toplevel_paths = PackedStringArray("/user/hand/left") + +[sub_resource type="OpenXRAction" id="Ergo_power_remove"] +resource_name = "power_remove" +localized_name = "Remove selected system power" +action_type = 0 +toplevel_paths = PackedStringArray("/user/hand/left") + +[sub_resource type="OpenXRAction" id="Ergo_power_previous"] +resource_name = "power_previous" +localized_name = "Previous system" +action_type = 0 +toplevel_paths = PackedStringArray("/user/hand/right") + +[sub_resource type="OpenXRAction" id="Ergo_power_next"] +resource_name = "power_next" +localized_name = "Next system" +action_type = 0 +toplevel_paths = PackedStringArray("/user/hand/right") + +[sub_resource type="OpenXRActionSet" id="OpenXRActionSet_ngwcy"] +resource_name = "godot" +localized_name = "Godot action set" +actions = [SubResource("Ergo_tactical_button"), SubResource("Ergo_power_remove"), SubResource("Ergo_power_previous"), SubResource("Ergo_power_next"), SubResource("Ergo_page_left"), SubResource("Ergo_page_right"), SubResource("Ergo_wheel_button"), SubResource("OpenXRAction_6ivru"), SubResource("OpenXRAction_vfhwq"), SubResource("OpenXRAction_5w03k"), SubResource("OpenXRAction_typ1r"), SubResource("OpenXRAction_clvbf"), SubResource("OpenXRAction_5bppb"), SubResource("OpenXRAction_3k6la"), SubResource("OpenXRAction_i8esw"), SubResource("OpenXRAction_um1hv"), SubResource("OpenXRAction_oqnsu"), SubResource("OpenXRAction_r5bl7"), SubResource("OpenXRAction_ytptc"), SubResource("OpenXRAction_iphn4"), SubResource("OpenXRAction_3p2as"), SubResource("OpenXRAction_wdehm"), SubResource("OpenXRAction_clfly"), SubResource("OpenXRAction_e1frq"), SubResource("OpenXRAction_l7aq8"), SubResource("OpenXRAction_oi0ij"), SubResource("OpenXRAction_m08eo"), SubResource("OpenXRAction_c4j1d"), SubResource("OpenXRAction_sopde"), SubResource("OpenXRAction_sow2k")] + +[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_r3qn1"] +action = SubResource("OpenXRAction_oi0ij") +binding_path = "/user/hand/left/input/aim/pose" + +[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_n01b8"] +action = SubResource("OpenXRAction_oi0ij") +binding_path = "/user/hand/right/input/aim/pose" + +[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_pjtev"] +action = SubResource("OpenXRAction_m08eo") +binding_path = "/user/hand/left/input/aim/pose" + +[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_nqyri"] +action = SubResource("OpenXRAction_m08eo") +binding_path = "/user/hand/right/input/aim/pose" + +[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_86uui"] +action = SubResource("OpenXRAction_c4j1d") +binding_path = "/user/hand/left/input/grip/pose" + +[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_nrtxc"] +action = SubResource("OpenXRAction_c4j1d") +binding_path = "/user/hand/right/input/grip/pose" + +[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_qovyo"] +action = SubResource("OpenXRAction_sopde") +binding_path = "/user/hand/left/input/grip_surface/pose" + +[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_d6uso"] +action = SubResource("OpenXRAction_sopde") +binding_path = "/user/hand/right/input/grip_surface/pose" + +[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_hvi7v"] +action = SubResource("OpenXRAction_wdehm") +binding_path = "/user/hand/left/input/primary/click" + +[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_7dxun"] +action = SubResource("OpenXRAction_wdehm") +binding_path = "/user/hand/right/input/primary/click" + +[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_rp8ih"] +action = SubResource("OpenXRAction_e1frq") +binding_path = "/user/hand/left/input/secondary/click" + +[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_0uca0"] +action = SubResource("OpenXRAction_e1frq") +binding_path = "/user/hand/right/input/secondary/click" + +[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_rjtq8"] +action = SubResource("OpenXRAction_6ivru") +binding_path = "/user/hand/left/input/trigger/value" + +[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_lce2q"] +action = SubResource("OpenXRAction_6ivru") +binding_path = "/user/hand/right/input/trigger/value" + +[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_ckeh6"] +action = SubResource("OpenXRAction_vfhwq") +binding_path = "/user/hand/left/input/trigger/value" + +[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_538mi"] +action = SubResource("OpenXRAction_vfhwq") +binding_path = "/user/hand/right/input/trigger/value" + +[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_548p5"] +action = SubResource("OpenXRAction_typ1r") +binding_path = "/user/hand/left/input/squeeze/value" + +[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_6o0wr"] +action = SubResource("OpenXRAction_typ1r") +binding_path = "/user/hand/right/input/squeeze/value" + +[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_fsghu"] +action = SubResource("OpenXRAction_clvbf") +binding_path = "/user/hand/left/input/squeeze/value" + +[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_88umk"] +action = SubResource("OpenXRAction_clvbf") +binding_path = "/user/hand/right/input/squeeze/value" + +[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_4uneg"] +action = SubResource("OpenXRAction_3k6la") +binding_path = "/user/hand/left/input/thumbstick" + +[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_67o31"] +action = SubResource("OpenXRAction_3k6la") +binding_path = "/user/hand/right/input/thumbstick" + +[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_lf1a1"] +action = SubResource("OpenXRAction_i8esw") +binding_path = "/user/hand/left/input/thumbstick/click" + +[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_x1adc"] +action = SubResource("OpenXRAction_i8esw") +binding_path = "/user/hand/right/input/thumbstick/click" + +[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_j1vtv"] +action = SubResource("OpenXRAction_sow2k") +binding_path = "/user/hand/left/output/haptic" + +[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_tud50"] +action = SubResource("OpenXRAction_sow2k") +binding_path = "/user/hand/right/output/haptic" + +[sub_resource type="OpenXRInteractionProfile" id="OpenXRInteractionProfile_akdt0"] +interaction_profile_path = "/interaction_profiles/khr/generic_controller" +bindings = [SubResource("OpenXRIPBinding_r3qn1"), SubResource("OpenXRIPBinding_n01b8"), SubResource("OpenXRIPBinding_pjtev"), SubResource("OpenXRIPBinding_nqyri"), SubResource("OpenXRIPBinding_86uui"), SubResource("OpenXRIPBinding_nrtxc"), SubResource("OpenXRIPBinding_qovyo"), SubResource("OpenXRIPBinding_d6uso"), SubResource("OpenXRIPBinding_hvi7v"), SubResource("OpenXRIPBinding_7dxun"), SubResource("OpenXRIPBinding_rp8ih"), SubResource("OpenXRIPBinding_0uca0"), SubResource("OpenXRIPBinding_rjtq8"), SubResource("OpenXRIPBinding_lce2q"), SubResource("OpenXRIPBinding_ckeh6"), SubResource("OpenXRIPBinding_538mi"), SubResource("OpenXRIPBinding_548p5"), SubResource("OpenXRIPBinding_6o0wr"), SubResource("OpenXRIPBinding_fsghu"), SubResource("OpenXRIPBinding_88umk"), SubResource("OpenXRIPBinding_4uneg"), SubResource("OpenXRIPBinding_67o31"), SubResource("OpenXRIPBinding_lf1a1"), SubResource("OpenXRIPBinding_x1adc"), SubResource("OpenXRIPBinding_j1vtv"), SubResource("OpenXRIPBinding_tud50")] + +[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_xri1r"] +action = SubResource("OpenXRAction_oi0ij") +binding_path = "/user/hand/left/input/aim/pose" + +[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_etqcv"] +action = SubResource("OpenXRAction_oi0ij") +binding_path = "/user/hand/right/input/aim/pose" + +[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_og5pg"] +action = SubResource("OpenXRAction_m08eo") +binding_path = "/user/hand/left/input/aim/pose" + +[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_nwe40"] +action = SubResource("OpenXRAction_m08eo") +binding_path = "/user/hand/right/input/aim/pose" + +[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_ts2ff"] +action = SubResource("OpenXRAction_c4j1d") +binding_path = "/user/hand/left/input/grip/pose" + +[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_yhsv0"] +action = SubResource("OpenXRAction_c4j1d") +binding_path = "/user/hand/right/input/grip/pose" + +[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_sf2dt"] +action = SubResource("OpenXRAction_sopde") +binding_path = "/user/hand/left/input/grip_surface/pose" + +[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_67dwi"] +action = SubResource("OpenXRAction_sopde") +binding_path = "/user/hand/right/input/grip_surface/pose" + +[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_hswdx"] +action = SubResource("OpenXRAction_iphn4") +binding_path = "/user/hand/left/input/menu/click" + +[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_7gr0f"] +action = SubResource("OpenXRAction_iphn4") +binding_path = "/user/hand/right/input/system/click" + +[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_uvspk"] +action = SubResource("OpenXRAction_wdehm") +binding_path = "/user/hand/left/input/x/click" + +[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_ica2g"] +action = SubResource("OpenXRAction_wdehm") +binding_path = "/user/hand/right/input/a/click" + +[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_5fecu"] +action = SubResource("OpenXRAction_clfly") +binding_path = "/user/hand/left/input/x/touch" + +[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_0nfxl"] +action = SubResource("OpenXRAction_clfly") +binding_path = "/user/hand/right/input/a/touch" + +[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_sbe0d"] +action = SubResource("OpenXRAction_e1frq") +binding_path = "/user/hand/left/input/y/click" + +[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_rf1ko"] +action = SubResource("OpenXRAction_e1frq") +binding_path = "/user/hand/right/input/b/click" + +[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_jx7ge"] +action = SubResource("OpenXRAction_l7aq8") +binding_path = "/user/hand/left/input/y/touch" + +[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_d2w1t"] +action = SubResource("OpenXRAction_l7aq8") +binding_path = "/user/hand/right/input/b/touch" + +[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_v2kct"] +action = SubResource("OpenXRAction_6ivru") +binding_path = "/user/hand/left/input/trigger/value" + +[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_37uq4"] +action = SubResource("OpenXRAction_6ivru") +binding_path = "/user/hand/right/input/trigger/value" + +[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_kooyb"] +action = SubResource("OpenXRAction_vfhwq") +binding_path = "/user/hand/left/input/trigger/value" + +[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_51qre"] +action = SubResource("OpenXRAction_vfhwq") +binding_path = "/user/hand/right/input/trigger/value" + +[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_fncxp"] +action = SubResource("OpenXRAction_5w03k") +binding_path = "/user/hand/left/input/trigger/touch" + +[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_qi50k"] +action = SubResource("OpenXRAction_5w03k") +binding_path = "/user/hand/right/input/trigger/touch" + +[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_h5icu"] +action = SubResource("OpenXRAction_typ1r") +binding_path = "/user/hand/left/input/squeeze/value" + +[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_b1sv6"] +action = SubResource("OpenXRAction_typ1r") +binding_path = "/user/hand/right/input/squeeze/value" + +[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_yu2t6"] +action = SubResource("OpenXRAction_clvbf") +binding_path = "/user/hand/left/input/squeeze/value" + +[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_labib"] +action = SubResource("OpenXRAction_clvbf") +binding_path = "/user/hand/right/input/squeeze/value" + +[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_altuc"] +action = SubResource("OpenXRAction_3k6la") +binding_path = "/user/hand/left/input/thumbstick" + +[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_7p0fp"] +action = SubResource("OpenXRAction_3k6la") +binding_path = "/user/hand/right/input/thumbstick" + +[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_yjnix"] +action = SubResource("OpenXRAction_i8esw") +binding_path = "/user/hand/left/input/thumbstick/click" + +[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_pgh0x"] +action = SubResource("OpenXRAction_i8esw") +binding_path = "/user/hand/right/input/thumbstick/click" + +[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_lplyu"] +action = SubResource("OpenXRAction_um1hv") +binding_path = "/user/hand/left/input/thumbstick/touch" + +[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_ifnya"] +action = SubResource("OpenXRAction_um1hv") +binding_path = "/user/hand/right/input/thumbstick/touch" + +[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_jl4vo"] +action = SubResource("OpenXRAction_sow2k") +binding_path = "/user/hand/left/output/haptic" + +[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_1n6j6"] +action = SubResource("OpenXRAction_sow2k") +binding_path = "/user/hand/right/output/haptic" + +[sub_resource type="OpenXRInteractionProfile" id="OpenXRInteractionProfile_o1nfs"] +interaction_profile_path = "/interaction_profiles/oculus/touch_controller" +bindings = [SubResource("OpenXRIPBinding_xri1r"), SubResource("OpenXRIPBinding_etqcv"), SubResource("OpenXRIPBinding_og5pg"), SubResource("OpenXRIPBinding_nwe40"), SubResource("OpenXRIPBinding_ts2ff"), SubResource("OpenXRIPBinding_yhsv0"), SubResource("OpenXRIPBinding_sf2dt"), SubResource("OpenXRIPBinding_67dwi"), SubResource("OpenXRIPBinding_hswdx"), SubResource("OpenXRIPBinding_7gr0f"), SubResource("OpenXRIPBinding_uvspk"), SubResource("OpenXRIPBinding_ica2g"), SubResource("OpenXRIPBinding_5fecu"), SubResource("OpenXRIPBinding_0nfxl"), SubResource("OpenXRIPBinding_sbe0d"), SubResource("OpenXRIPBinding_rf1ko"), SubResource("OpenXRIPBinding_jx7ge"), SubResource("OpenXRIPBinding_d2w1t"), SubResource("OpenXRIPBinding_v2kct"), SubResource("OpenXRIPBinding_37uq4"), SubResource("OpenXRIPBinding_kooyb"), SubResource("OpenXRIPBinding_51qre"), SubResource("OpenXRIPBinding_fncxp"), SubResource("OpenXRIPBinding_qi50k"), SubResource("OpenXRIPBinding_h5icu"), SubResource("OpenXRIPBinding_b1sv6"), SubResource("OpenXRIPBinding_yu2t6"), SubResource("OpenXRIPBinding_labib"), SubResource("OpenXRIPBinding_altuc"), SubResource("OpenXRIPBinding_7p0fp"), SubResource("OpenXRIPBinding_yjnix"), SubResource("OpenXRIPBinding_pgh0x"), SubResource("OpenXRIPBinding_lplyu"), SubResource("OpenXRIPBinding_ifnya"), SubResource("OpenXRIPBinding_jl4vo"), SubResource("OpenXRIPBinding_1n6j6")] + +[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_unnrh"] +action = SubResource("OpenXRAction_oi0ij") +binding_path = "/user/hand/left/input/aim/pose" + +[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_3wafl"] +action = SubResource("OpenXRAction_oi0ij") +binding_path = "/user/hand/right/input/aim/pose" + +[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_tjb53"] +action = SubResource("OpenXRAction_m08eo") +binding_path = "/user/hand/left/input/aim/pose" + +[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_lcg2b"] +action = SubResource("OpenXRAction_m08eo") +binding_path = "/user/hand/right/input/aim/pose" + +[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_sp6l2"] +action = SubResource("OpenXRAction_c4j1d") +binding_path = "/user/hand/left/input/grip/pose" + +[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_xj6ir"] +action = SubResource("OpenXRAction_c4j1d") +binding_path = "/user/hand/right/input/grip/pose" + +[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_igmf3"] +action = SubResource("OpenXRAction_sopde") +binding_path = "/user/hand/left/input/grip_surface/pose" + +[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_inw5v"] +action = SubResource("OpenXRAction_sopde") +binding_path = "/user/hand/right/input/grip_surface/pose" + +[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_iy2wq"] +action = SubResource("OpenXRAction_3p2as") +binding_path = "/user/hand/left/input/system/click" + +[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_plu03"] +action = SubResource("OpenXRAction_3p2as") +binding_path = "/user/hand/right/input/system/click" + +[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_dad45"] +action = SubResource("OpenXRAction_iphn4") +binding_path = "/user/hand/left/input/menu/click" + +[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_m5e8q"] +action = SubResource("OpenXRAction_wdehm") +binding_path = "/user/hand/left/input/x/click" + +[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_5t7jh"] +action = SubResource("OpenXRAction_wdehm") +binding_path = "/user/hand/right/input/a/click" + +[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_chplt"] +action = SubResource("OpenXRAction_clfly") +binding_path = "/user/hand/left/input/x/touch" + +[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_obxrh"] +action = SubResource("OpenXRAction_clfly") +binding_path = "/user/hand/right/input/a/touch" + +[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_on7oi"] +action = SubResource("OpenXRAction_e1frq") +binding_path = "/user/hand/left/input/y/click" + +[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_ege4h"] +action = SubResource("OpenXRAction_e1frq") +binding_path = "/user/hand/right/input/b/click" + +[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_h7ix0"] +action = SubResource("OpenXRAction_l7aq8") +binding_path = "/user/hand/left/input/y/touch" + +[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_8qanm"] +action = SubResource("OpenXRAction_l7aq8") +binding_path = "/user/hand/right/input/b/touch" + +[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_3senm"] +action = SubResource("OpenXRAction_6ivru") +binding_path = "/user/hand/left/input/trigger/value" + +[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_7ca55"] +action = SubResource("OpenXRAction_6ivru") +binding_path = "/user/hand/right/input/trigger/value" + +[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_ih1l2"] +action = SubResource("OpenXRAction_vfhwq") +binding_path = "/user/hand/left/input/trigger/value" + +[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_ipewn"] +action = SubResource("OpenXRAction_vfhwq") +binding_path = "/user/hand/right/input/trigger/value" + +[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_5ngl7"] +action = SubResource("OpenXRAction_5w03k") +binding_path = "/user/hand/left/input/trigger/touch" + +[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_klygg"] +action = SubResource("OpenXRAction_5w03k") +binding_path = "/user/hand/right/input/trigger/touch" + +[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_4p63k"] +action = SubResource("OpenXRAction_typ1r") +binding_path = "/user/hand/left/input/squeeze/value" + +[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_6vi2m"] +action = SubResource("OpenXRAction_typ1r") +binding_path = "/user/hand/right/input/squeeze/value" + +[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_888d1"] +action = SubResource("OpenXRAction_clvbf") +binding_path = "/user/hand/left/input/squeeze/value" + +[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_246v5"] +action = SubResource("OpenXRAction_clvbf") +binding_path = "/user/hand/right/input/squeeze/value" + +[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_xj73r"] +action = SubResource("OpenXRAction_3k6la") +binding_path = "/user/hand/left/input/thumbstick" + +[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_sugej"] +action = SubResource("OpenXRAction_3k6la") +binding_path = "/user/hand/right/input/thumbstick" + +[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_fp7u7"] +action = SubResource("OpenXRAction_i8esw") +binding_path = "/user/hand/left/input/thumbstick/click" + +[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_gvgeq"] +action = SubResource("OpenXRAction_i8esw") +binding_path = "/user/hand/right/input/thumbstick/click" + +[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_i0s8c"] +action = SubResource("OpenXRAction_um1hv") +binding_path = "/user/hand/left/input/thumbstick/touch" + +[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_ynetq"] +action = SubResource("OpenXRAction_um1hv") +binding_path = "/user/hand/right/input/thumbstick/touch" + +[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_p8bcx"] +action = SubResource("OpenXRAction_sow2k") +binding_path = "/user/hand/left/output/haptic" + +[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_jkemj"] +action = SubResource("OpenXRAction_sow2k") +binding_path = "/user/hand/right/output/haptic" + +[sub_resource type="OpenXRInteractionProfile" id="OpenXRInteractionProfile_d3nfp"] +interaction_profile_path = "/interaction_profiles/bytedance/pico4_controller" +bindings = [SubResource("OpenXRIPBinding_unnrh"), SubResource("OpenXRIPBinding_3wafl"), SubResource("OpenXRIPBinding_tjb53"), SubResource("OpenXRIPBinding_lcg2b"), SubResource("OpenXRIPBinding_sp6l2"), SubResource("OpenXRIPBinding_xj6ir"), SubResource("OpenXRIPBinding_igmf3"), SubResource("OpenXRIPBinding_inw5v"), SubResource("OpenXRIPBinding_iy2wq"), SubResource("OpenXRIPBinding_plu03"), SubResource("OpenXRIPBinding_dad45"), SubResource("OpenXRIPBinding_m5e8q"), SubResource("OpenXRIPBinding_5t7jh"), SubResource("OpenXRIPBinding_chplt"), SubResource("OpenXRIPBinding_obxrh"), SubResource("OpenXRIPBinding_on7oi"), SubResource("OpenXRIPBinding_ege4h"), SubResource("OpenXRIPBinding_h7ix0"), SubResource("OpenXRIPBinding_8qanm"), SubResource("OpenXRIPBinding_3senm"), SubResource("OpenXRIPBinding_7ca55"), SubResource("OpenXRIPBinding_ih1l2"), SubResource("OpenXRIPBinding_ipewn"), SubResource("OpenXRIPBinding_5ngl7"), SubResource("OpenXRIPBinding_klygg"), SubResource("OpenXRIPBinding_4p63k"), SubResource("OpenXRIPBinding_6vi2m"), SubResource("OpenXRIPBinding_888d1"), SubResource("OpenXRIPBinding_246v5"), SubResource("OpenXRIPBinding_xj73r"), SubResource("OpenXRIPBinding_sugej"), SubResource("OpenXRIPBinding_fp7u7"), SubResource("OpenXRIPBinding_gvgeq"), SubResource("OpenXRIPBinding_i0s8c"), SubResource("OpenXRIPBinding_ynetq"), SubResource("OpenXRIPBinding_p8bcx"), SubResource("OpenXRIPBinding_jkemj")] + +[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_jyu76"] +action = SubResource("OpenXRAction_oi0ij") +binding_path = "/user/hand/left/input/aim/pose" + +[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_aecy5"] +action = SubResource("OpenXRAction_oi0ij") +binding_path = "/user/hand/right/input/aim/pose" + +[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_8vb80"] +action = SubResource("OpenXRAction_m08eo") +binding_path = "/user/hand/left/input/aim/pose" + +[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_myee0"] +action = SubResource("OpenXRAction_m08eo") +binding_path = "/user/hand/right/input/aim/pose" + +[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_1vv3a"] +action = SubResource("OpenXRAction_c4j1d") +binding_path = "/user/hand/left/input/grip/pose" + +[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_tjysa"] +action = SubResource("OpenXRAction_c4j1d") +binding_path = "/user/hand/right/input/grip/pose" + +[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_r4yxn"] +action = SubResource("OpenXRAction_sopde") +binding_path = "/user/hand/left/input/grip_surface/pose" + +[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_me87v"] +action = SubResource("OpenXRAction_sopde") +binding_path = "/user/hand/right/input/grip_surface/pose" + +[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_d8myu"] +action = SubResource("OpenXRAction_6ivru") +binding_path = "/user/hand/left/input/pinch_ext/value" + +[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_hsh5n"] +action = SubResource("OpenXRAction_6ivru") +binding_path = "/user/hand/right/input/pinch_ext/value" + +[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_lng5j"] +action = SubResource("OpenXRAction_vfhwq") +binding_path = "/user/hand/left/input/pinch_ext/value" + +[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_aeeoj"] +action = SubResource("OpenXRAction_vfhwq") +binding_path = "/user/hand/right/input/pinch_ext/value" + +[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_gosqu"] +action = SubResource("OpenXRAction_typ1r") +binding_path = "/user/hand/left/input/grasp_ext/value" + +[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_n52fm"] +action = SubResource("OpenXRAction_typ1r") +binding_path = "/user/hand/right/input/grasp_ext/value" + +[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_vushy"] +action = SubResource("OpenXRAction_clvbf") +binding_path = "/user/hand/left/input/grasp_ext/value" + +[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_lbhgg"] +action = SubResource("OpenXRAction_clvbf") +binding_path = "/user/hand/right/input/grasp_ext/value" + +[sub_resource type="OpenXRInteractionProfile" id="OpenXRInteractionProfile_m1cgb"] +interaction_profile_path = "/interaction_profiles/ext/hand_interaction_ext" +bindings = [SubResource("OpenXRIPBinding_jyu76"), SubResource("OpenXRIPBinding_aecy5"), SubResource("OpenXRIPBinding_8vb80"), SubResource("OpenXRIPBinding_myee0"), SubResource("OpenXRIPBinding_1vv3a"), SubResource("OpenXRIPBinding_tjysa"), SubResource("OpenXRIPBinding_r4yxn"), SubResource("OpenXRIPBinding_me87v"), SubResource("OpenXRIPBinding_d8myu"), SubResource("OpenXRIPBinding_hsh5n"), SubResource("OpenXRIPBinding_lng5j"), SubResource("OpenXRIPBinding_aeeoj"), SubResource("OpenXRIPBinding_gosqu"), SubResource("OpenXRIPBinding_n52fm"), SubResource("OpenXRIPBinding_vushy"), SubResource("OpenXRIPBinding_lbhgg")] + +[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_yfktj"] +action = SubResource("OpenXRAction_oi0ij") +binding_path = "/user/hand/left/input/aim/pose" + +[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_kjhen"] +action = SubResource("OpenXRAction_m08eo") +binding_path = "/user/hand/left/input/aim/pose" + +[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_32kw4"] +action = SubResource("OpenXRAction_c4j1d") +binding_path = "/user/hand/left/input/grip/pose" + +[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_ktbxl"] +action = SubResource("OpenXRAction_6ivru") +binding_path = "/user/hand/left/input/trigger/value" + +[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_8ldfe"] +action = SubResource("OpenXRAction_vfhwq") +binding_path = "/user/hand/left/input/trigger/click" + +[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_nueak"] +action = SubResource("OpenXRAction_typ1r") +binding_path = "/user/hand/left/input/squeeze/value" + +[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_vopyr"] +action = SubResource("OpenXRAction_clvbf") +binding_path = "/user/hand/left/input/squeeze/click" + +[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_rgbyv"] +action = SubResource("OpenXRAction_3k6la") +binding_path = "/user/hand/left/input/thumbstick" + +[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_bflds"] +action = SubResource("OpenXRAction_i8esw") +binding_path = "/user/hand/left/input/thumbstick/click" + +[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_pueci"] +action = SubResource("OpenXRAction_sow2k") +binding_path = "/user/hand/left/output/haptic" + +[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_jn5l0"] +action = SubResource("Ergo_tactical_button") +binding_path = "/user/hand/left/input/dpad_down/click" + +[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_44ra8"] +action = SubResource("OpenXRAction_e1frq") +binding_path = "/user/hand/left/input/dpad_up/click" + +[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_bh82f"] +action = SubResource("OpenXRAction_iphn4") +binding_path = "/user/hand/left/input/view/click" + +[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_7b312"] +action = SubResource("OpenXRAction_oi0ij") +binding_path = "/user/hand/right/input/aim/pose" + +[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_ajt26"] +action = SubResource("OpenXRAction_m08eo") +binding_path = "/user/hand/right/input/aim/pose" + +[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_grl1h"] +action = SubResource("OpenXRAction_c4j1d") +binding_path = "/user/hand/right/input/grip/pose" + +[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_dlpx3"] +action = SubResource("OpenXRAction_6ivru") +binding_path = "/user/hand/right/input/trigger/value" + +[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_s4h6a"] +action = SubResource("OpenXRAction_vfhwq") +binding_path = "/user/hand/right/input/trigger/click" + +[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_0njdn"] +action = SubResource("OpenXRAction_typ1r") +binding_path = "/user/hand/right/input/squeeze/value" + +[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_xtpgr"] +action = SubResource("OpenXRAction_clvbf") +binding_path = "/user/hand/right/input/squeeze/click" + +[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_sm8ja"] +action = SubResource("OpenXRAction_3k6la") +binding_path = "/user/hand/right/input/thumbstick" + +[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_fyyqw"] +action = SubResource("OpenXRAction_i8esw") +binding_path = "/user/hand/right/input/thumbstick/click" + +[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_6yfaw"] +action = SubResource("OpenXRAction_sow2k") +binding_path = "/user/hand/right/output/haptic" + +[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_x7rhh"] +action = SubResource("OpenXRAction_wdehm") +binding_path = "/user/hand/right/input/a/click" + +[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_8cuio"] +action = SubResource("OpenXRAction_e1frq") +binding_path = "/user/hand/right/input/b/click" + +[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_v0kom"] +action = SubResource("OpenXRAction_iphn4") +binding_path = "/user/hand/right/input/menu/click" + +[sub_resource type="OpenXRIPBinding" id="Ergo_binding_page_left"] +action = SubResource("Ergo_page_left") +binding_path = "/user/hand/left/input/dpad_left/click" + +[sub_resource type="OpenXRIPBinding" id="Ergo_binding_page_right"] +action = SubResource("Ergo_page_right") +binding_path = "/user/hand/left/input/dpad_right/click" + +[sub_resource type="OpenXRIPBinding" id="Ergo_binding_wheel_button"] +action = SubResource("Ergo_wheel_button") +binding_path = "/user/hand/right/input/bumper/click" + +[sub_resource type="OpenXRIPBinding" id="Ergo_binding_power_remove"] +action = SubResource("Ergo_power_remove") +binding_path = "/user/hand/left/input/bumper/click" + +[sub_resource type="OpenXRIPBinding" id="Ergo_binding_power_previous"] +action = SubResource("Ergo_power_previous") +binding_path = "/user/hand/right/input/x/click" + +[sub_resource type="OpenXRIPBinding" id="Ergo_binding_power_next"] +action = SubResource("Ergo_power_next") +binding_path = "/user/hand/right/input/y/click" + +[sub_resource type="OpenXRInteractionProfile" id="OpenXRInteractionProfile_v1men"] +interaction_profile_path = "/interaction_profiles/valve/frame_controller_valve" +bindings = [SubResource("Ergo_binding_power_remove"), SubResource("Ergo_binding_power_previous"), SubResource("Ergo_binding_power_next"), SubResource("Ergo_binding_page_left"), SubResource("Ergo_binding_page_right"), SubResource("Ergo_binding_wheel_button"), SubResource("OpenXRIPBinding_yfktj"), SubResource("OpenXRIPBinding_kjhen"), SubResource("OpenXRIPBinding_32kw4"), SubResource("OpenXRIPBinding_ktbxl"), SubResource("OpenXRIPBinding_8ldfe"), SubResource("OpenXRIPBinding_nueak"), SubResource("OpenXRIPBinding_vopyr"), SubResource("OpenXRIPBinding_rgbyv"), SubResource("OpenXRIPBinding_bflds"), SubResource("OpenXRIPBinding_pueci"), SubResource("OpenXRIPBinding_jn5l0"), SubResource("OpenXRIPBinding_44ra8"), SubResource("OpenXRIPBinding_bh82f"), SubResource("OpenXRIPBinding_7b312"), SubResource("OpenXRIPBinding_ajt26"), SubResource("OpenXRIPBinding_grl1h"), SubResource("OpenXRIPBinding_dlpx3"), SubResource("OpenXRIPBinding_s4h6a"), SubResource("OpenXRIPBinding_0njdn"), SubResource("OpenXRIPBinding_xtpgr"), SubResource("OpenXRIPBinding_sm8ja"), SubResource("OpenXRIPBinding_fyyqw"), SubResource("OpenXRIPBinding_6yfaw"), SubResource("OpenXRIPBinding_x7rhh"), SubResource("OpenXRIPBinding_8cuio"), SubResource("OpenXRIPBinding_v0kom")] + +[resource] +action_sets = [SubResource("OpenXRActionSet_ngwcy")] +interaction_profiles = [SubResource("OpenXRInteractionProfile_akdt0"), SubResource("OpenXRInteractionProfile_o1nfs"), SubResource("OpenXRInteractionProfile_d3nfp"), SubResource("OpenXRInteractionProfile_m1cgb"), SubResource("OpenXRInteractionProfile_v1men")] diff --git a/project.godot b/project.godot new file mode 100644 index 0000000..4539109 --- /dev/null +++ b/project.godot @@ -0,0 +1,19 @@ +config_version=5 + +[application] +config/name="FTL Tabletop VR Prototype" +run/main_scene="res://main.tscn" + +[display] +window/size/viewport_width=1280 +window/size/viewport_height=720 + +[rendering] +renderer/rendering_method="mobile" +renderer/rendering_method.mobile="mobile" +textures/default_filters/use_nearest_mipmap_filter=false + +[xr] +openxr/enabled=true +openxr/default_action_map="res://openxr_action_map.tres" +shaders/enabled=true diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..d329af6 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +frida==17.9.0 +Pillow>=10 +numpy>=1.24 +capstone==5.0.9 diff --git a/scripts/combat_effects.gd b/scripts/combat_effects.gd new file mode 100644 index 0000000..8a28654 --- /dev/null +++ b/scripts/combat_effects.gd @@ -0,0 +1,326 @@ +extends Node3D + +const NATIVE_PRESENTATION_GRACE_MS := 300 + +var shots: Array[Dictionary] = [] +var impacts: Array[Dictionary] = [] +var simulation_paused := false +var unit_beam_mesh: CylinderMesh + + +func spawn_shot(event: Dictionary, sender: Node3D, receiver: Node3D) -> void: + 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")): + origin_ship = receiver + 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("origin_point"): + start = origin_ship.to_global(origin_ship.pixel_point(event["origin_point"])) + var end: Vector3 = receiver.room_target(int(event.get("target_room", 0))) + if event.has("target") and not event["target"].is_empty(): + end = receiver.to_global(receiver.pixel_point(event["target"])) + var outcome := str(event.get("outcome", "hull")) + if outcome == "shield": + end = receiver.shield_intercept(start, end) + elif outcome == "miss": + end += Vector3(0.0, 0.5, 1.0) + var color := Color(0.3, 0.75, 1.0) if kind == "ion" else Color(1.0, 0.35, 0.12) + if kind == "missile": + color = Color(0.9, 0.9, 0.85) + elif kind == "asteroid": + color = Color(0.4, 0.42, 0.45) + var node := _projectile_visual(kind, color) + add_child(node) + node.global_position = start + var duration := maxf(0.01, float(event.get("duration", 0.7))) + var beam_end: Vector3 = receiver.room_target(int(event.get("end_room", event.get("target_room", 0)))) + if event.has("end_point"): + beam_end = receiver.to_global(receiver.pixel_point(event["end_point"])) + if kind == "beam": + if outcome == "shield": + beam_end = receiver.shield_intercept(start, beam_end) + elif kind == "bomb": + 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, + "created_ms": Time.get_ticks_msec(), "native_present": false, + "visual_scale": maxf(0.1, sender.global_basis.get_scale().abs().x), + "live_progress": 0.0, "wanted_progress": 0.0, "missed": false, + "end_point": event.get("end_point", {}), + "origin_point": event.get("origin_point", {}), "origin_ship": origin_ship, + "target_point": event.get("target", {}), "beam_point": {}, + "slot": int(event.get("weapon_slot", 0)), "room": int(event.get("target_room", 0)), + "time": 0.0, "duration": duration, "kind": kind, "outcome": outcome, "receiver": receiver}) + + +func _process(delta: float) -> void: + if simulation_paused: + return + for i in range(impacts.size() - 1, -1, -1): + impacts[i]["time"] += delta + var flash: MeshInstance3D = impacts[i]["node"] + var age := float(impacts[i]["time"]) / 0.22 + flash.scale = Vector3.ONE * maxf(0.01, 1.0 - age) + var ring := flash.get_node_or_null("ImpactRing") as MeshInstance3D + if ring != null: + ring.scale = Vector3.ONE * (1.0 + age * 3.0) + if float(impacts[i]["time"]) >= 0.22: + flash.queue_free() + impacts.remove_at(i) + for i in range(shots.size() - 1, -1, -1): + var shot: Dictionary = shots[i] + shot["time"] += delta + # Recompute endpoints after the user moves/rotates/scales the encounter. + shot["visual_scale"] = maxf(0.1, shot["sender"].global_basis.get_scale().abs().x) + shot["start"] = shot["sender"].weapon_origin(shot["slot"]) + if 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"])) + if shot["missed"]: + shot["end"] += Vector3(0.0, 0.25, 0.5) + if not shot["end_point"].is_empty(): + shot["beam_end"] = shot["receiver"].to_global(shot["receiver"].pixel_point(shot["end_point"])) + var progress := clampf(float(shot["time"]) / float(shot["duration"]), 0.0, 1.0) + if shot["outcome"] == "pending": + shot["live_progress"] = lerpf(shot["live_progress"], shot["wanted_progress"], minf(1.0, delta * 20.0)) + progress = shot["live_progress"] + if shot["time"] > 20.0: + progress = 1.0 + var node: MeshInstance3D = shot["node"] + if shot["kind"] == "beam": + var beam_target: Vector3 = Vector3(shot["end"]).lerp(shot["beam_end"], progress) + if not shot["beam_point"].is_empty(): + beam_target = shot["receiver"].to_global(shot["receiver"].pixel_point(shot["beam_point"])) + var line: ImmediateMesh = node.mesh + line.clear_surfaces() + line.surface_begin(Mesh.PRIMITIVE_LINES) + line.surface_add_vertex(to_local(shot["start"])) + line.surface_add_vertex(to_local(beam_target)) + line.surface_end() + node.position = Vector3.ZERO + # Keep the precise native sweep line, then surround it with real 3D + # cylinders so its width remains visible in stereo at oblique angles. + _segment(node.get_node("BeamCore"), shot["start"], beam_target, 0.012 * shot["visual_scale"]) + _segment(node.get_node("BeamGlow"), shot["start"], beam_target, 0.032 * shot["visual_scale"]) + elif shot["kind"] != "bomb": + node.global_position = Vector3(shot["start"]).lerp(shot["end"], progress) + var direction: Vector3 = Vector3(shot["end"]) - Vector3(shot["start"]) + 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"]) + if progress >= 1.0: + if shot["outcome"] == "shield": + shot["receiver"].flash_shield() + if shot["outcome"] not in ["miss", "pending"]: + _impact(shot["beam_end"] if shot["kind"] == "beam" else shot["end"], shot["kind"], shot["outcome"] == "shield") + node.queue_free() + shots.remove_at(i) + + +func resolve_live(event: Dictionary, receiver: Node3D) -> void: + var projectile_id := str(event.get("projectile_id", "")) + var point: Vector3 = receiver.to_global(receiver.pixel_point(event.get("target", {}))) + if event.get("outcome", "") == "beam": + _impact(point, "beam") + return + var kind := str(event.get("kind", "laser")) + for i in range(shots.size() - 1, -1, -1): + if shots[i]["projectile_id"] == projectile_id: + kind = str(shots[i]["kind"]) + if not event.has("target"): + point = shots[i]["end"] + if event.get("outcome", "") == "shield": + point = receiver.shield_intercept(shots[i]["start"], point) + shots[i]["node"].queue_free() + shots.remove_at(i) + if event.get("outcome", "") == "shield": + receiver.flash_shield() + if event.get("outcome", "") in ["shield", "hull"]: + _impact(point, kind, event.get("outcome", "") == "shield") + + +func update_live_projectiles(projectiles: Array) -> void: + var present: Dictionary = {} + for projectile in projectiles: + present[str(projectile["id"])] = projectile + for i in range(shots.size() - 1, -1, -1): + var shot: Dictionary = shots[i] + if shot["outcome"] != "pending": + continue + 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"] = bool(projectile.get("missed", false)) + shot["beam_point"] = projectile.get("beam_point", {}) + elif shot["native_present"] or Time.get_ticks_msec() - int(shot["created_ms"]) >= NATIVE_PRESENTATION_GRACE_MS: + # Native absence ends a known projectile immediately. Newly delivered + # fire records get a short presentation grace for the next snapshot; + # its monotonic clock keeps working while gameplay is paused. + shot["node"].queue_free() + shots.remove_at(i) + + +func _impact(point: Vector3, kind: String = "laser", shield: bool = false) -> void: + var flash := MeshInstance3D.new() + var sphere := SphereMesh.new() + sphere.radius = 0.025 if kind not in ["missile", "bomb"] else 0.04 + sphere.height = sphere.radius * 2.0 + sphere.radial_segments = 10 + sphere.rings = 5 + flash.mesh = sphere + var color := Color(0.32, 0.73, 1.0) if shield or kind == "ion" else Color(1.0, 0.78, 0.28) + flash.material_override = _glow(color) + flash.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF + var ring := MeshInstance3D.new() + ring.name = "ImpactRing" + var torus := TorusMesh.new() + torus.inner_radius = 0.024 + torus.outer_radius = 0.028 + torus.rings = 16 + torus.ring_segments = 6 + ring.mesh = torus + ring.material_override = _glow(Color(color, 0.45), true) + ring.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF + flash.add_child(ring) + if kind in ["missile", "bomb"]: + for i in range(6): + var spark := _box(Vector3(0.007, 0.007, 0.025), Color(1.0, 0.38, 0.05)) + var direction := Vector3(cos(i * TAU / 6.0), 0.3, sin(i * TAU / 6.0)) + spark.position = direction * 0.04 + spark.basis = Basis.looking_at(direction, Vector3.UP) + flash.add_child(spark) + add_child(flash) + flash.global_position = point + impacts.append({"node": flash, "time": 0.0}) + + +func _projectile_visual(kind: String, color: Color) -> MeshInstance3D: + var node := MeshInstance3D.new() + node.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF + node.material_override = _glow(color) + match kind: + "beam": + node.mesh = ImmediateMesh.new() + if unit_beam_mesh == null: + unit_beam_mesh = CylinderMesh.new() + unit_beam_mesh.height = 1.0 + unit_beam_mesh.top_radius = 1.0 + unit_beam_mesh.bottom_radius = 1.0 + unit_beam_mesh.radial_segments = 8 + for part in ["BeamCore", "BeamGlow"]: + var cylinder := MeshInstance3D.new() + cylinder.name = part + cylinder.mesh = unit_beam_mesh + cylinder.material_override = _glow(Color(1.0, 0.94, 0.67) if part == "BeamCore" else Color(1.0, 0.35, 0.1, 0.2), part == "BeamGlow") + cylinder.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF + node.add_child(cylinder) + "missile": + var body := BoxMesh.new() + body.size = Vector3(0.075, 0.065, 0.19) + node.mesh = body + node.material_override = _solid(Color(0.72, 0.76, 0.79)) + var nose := MeshInstance3D.new() + var pyramid := CylinderMesh.new() + pyramid.top_radius = 0.0 + pyramid.bottom_radius = 0.052 + pyramid.height = 0.072 + pyramid.radial_segments = 4 + nose.mesh = pyramid + nose.position.z = -0.128 + nose.rotation.x = -PI / 2.0 + nose.material_override = _solid(Color(0.73, 0.17, 0.08)) + node.add_child(nose) + for axis in [Vector3.RIGHT, Vector3.UP]: + var fin := _box(Vector3(0.14, 0.012, 0.065) if axis == Vector3.RIGHT else Vector3(0.012, 0.12, 0.065), Color(0.27, 0.3, 0.33), false) + fin.position.z = 0.068 + node.add_child(fin) + var exhaust := _box(Vector3(0.035, 0.035, 0.12), Color(1.0, 0.36, 0.08, 0.65)) + exhaust.position.z = 0.145 + node.add_child(exhaust) + "ion": + var sphere := SphereMesh.new() + sphere.radius = 0.052 + sphere.height = 0.104 + sphere.radial_segments = 10 + sphere.rings = 5 + node.mesh = sphere + for axis in [0, 1]: + var ring := MeshInstance3D.new() + var torus := TorusMesh.new() + torus.inner_radius = 0.066 + torus.outer_radius = 0.078 + torus.rings = 12 + torus.ring_segments = 4 + ring.mesh = torus + ring.rotation.x = PI / 2.0 if axis == 0 else 0.0 + ring.material_override = _glow(Color(0.15, 0.6, 1.0, 0.6), true) + ring.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF + node.add_child(ring) + "asteroid": + var sphere := SphereMesh.new() + sphere.radius = 0.2 + sphere.height = 0.34 + sphere.radial_segments = 7 + sphere.rings = 4 + node.mesh = sphere + node.material_override = _solid(color) + _: + var core := BoxMesh.new() + core.size = Vector3(0.028, 0.028, 0.26) + node.mesh = core + node.material_override = _glow(Color(1.0, 0.93, 0.72)) + var trail := _box(Vector3(0.066, 0.066, 0.39), Color(color, 0.27)) + trail.position.z = 0.07 + node.add_child(trail) + return node + + +func _box(size: Vector3, color: Color, luminous: bool = true) -> MeshInstance3D: + var node := MeshInstance3D.new() + var mesh := BoxMesh.new() + mesh.size = size + node.mesh = mesh + node.material_override = _glow(color, color.a < 1.0) if luminous else _solid(color) + node.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF + return node + + +func _glow(color: Color, additive: bool = false) -> StandardMaterial3D: + var material := StandardMaterial3D.new() + material.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED + material.albedo_color = color + material.emission_enabled = true + material.emission = Color(color, 1.0) + if additive: + material.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA + material.blend_mode = BaseMaterial3D.BLEND_MODE_ADD + return material + + +func _solid(color: Color) -> StandardMaterial3D: + var material := StandardMaterial3D.new() + material.albedo_color = color + material.roughness = 0.82 + return material + + +func _segment(node: MeshInstance3D, start: Vector3, end: Vector3, radius: float) -> void: + var direction := end - start + var length := direction.length() + node.visible = length > 0.00001 + if not node.visible: + return + var axis := direction / length + var reference := Vector3.RIGHT if absf(axis.dot(Vector3.UP)) > 0.98 else Vector3.UP + var side := reference.cross(axis).normalized() + # A shared unit cylinder stays immutable. Transforming it avoids rebuilding + # two procedural meshes on every beam sweep frame. + node.global_basis = Basis(side * radius, axis * length, side.cross(axis) * radius) + node.global_position = (start + end) * 0.5 diff --git a/scripts/combat_effects.gd.uid b/scripts/combat_effects.gd.uid new file mode 100644 index 0000000..667aea6 --- /dev/null +++ b/scripts/combat_effects.gd.uid @@ -0,0 +1 @@ +uid://dhy7cqbe4ytgj diff --git a/scripts/controller_help.gd b/scripts/controller_help.gd new file mode 100644 index 0000000..6b8cacc --- /dev/null +++ b/scripts/controller_help.gd @@ -0,0 +1,150 @@ +extends Node3D + +const UI = preload("res://scripts/ftl_ui_theme.gd") + +# An original vector schematic, rendered once. It labels the active bindings +# rather than reproducing controller industrial design or extracted artwork. +var surface_size := Vector2(0.88, 0.53) +var render_priority := 122 +var viewport: SubViewport +var canvas: Control + + +func _ready() -> void: + viewport = SubViewport.new() + viewport.size = Vector2i(1000, 600) + viewport.transparent_bg = true + viewport.render_target_update_mode = SubViewport.UPDATE_ONCE + add_child(viewport) + canvas = HelpCanvas.new() + canvas.size = Vector2(1000, 600) + canvas.texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST + viewport.add_child(canvas) + var display := MeshInstance3D.new() + var quad := QuadMesh.new() + quad.size = surface_size + display.mesh = quad + display.position.y = 0.03 + display.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF + var material := StandardMaterial3D.new() + material.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED + material.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA + material.no_depth_test = true + material.render_priority = render_priority + material.texture_filter = BaseMaterial3D.TEXTURE_FILTER_NEAREST + material.albedo_texture = viewport.get_texture() + display.material_override = material + add_child(display) + + +class HelpCanvas extends Control: + const INK := UI.INK + const GREEN := UI.GREEN + const MUTED := UI.MUTED + const DARK := UI.DARK + var font: Font = UI.ASSETS.font("body") + + + func _label(value: String, at: Vector2, font_size: int = 20, color: Color = INK) -> void: + draw_string(font,at,value,HORIZONTAL_ALIGNMENT_LEFT,-1.0,roundi(font_size * 1.45),color) + + + func _leader(pin: Vector2, edge: Vector2, channel_x: float) -> void: + var points := PackedVector2Array([pin, Vector2(channel_x, pin.y), Vector2(channel_x, edge.y), edge]) + draw_polyline(points, MUTED, 2.0, true) + draw_circle(pin, 4.0, GREEN) + + + func _callout(title: String, lines: Array, at: Vector2, pin: Vector2, left_side: bool) -> void: + _label(title, at, 20, GREEN) + for i in range(lines.size()): + _label(str(lines[i]), at + Vector2(0, 22 * (i + 1)), 19) + var edge := Vector2(199 if left_side else 771, at.y + 5) + _leader(pin, edge, 210 if left_side else 750) + + + func _controller(center_x: float, right: bool) -> void: + var outline := PackedVector2Array([ + Vector2(center_x - 67, 153), Vector2(center_x - 52, 136), + Vector2(center_x - 24, 124), Vector2(center_x + 31, 132), + Vector2(center_x + 61, 152), Vector2(center_x + 72, 202), + Vector2(center_x + 44, 241), Vector2(center_x + 38, 321), + Vector2(center_x + 20, 350), Vector2(center_x - 23, 350), + Vector2(center_x - 43, 320), Vector2(center_x - 46, 240), + Vector2(center_x - 68, 199)]) + draw_colored_polygon(outline, Color(0.065, 0.125, 0.139)) + var border := outline.duplicate() + border.append(outline[0]) + draw_polyline(border, INK, 3.0, true) + for line in range(4): + draw_line(Vector2(center_x - 26, 299 + line * 10), Vector2(center_x + 24, 299 + line * 10), MUTED, 2.0, true) + var stick := Vector2(center_x - 17, 185) + draw_circle(stick, 26, DARK) + draw_arc(stick, 26, 0, TAU, 32, GREEN, 3.0, true) + draw_circle(stick, 11, MUTED) + _label("STICK", stick + Vector2(-26, -36), 16, MUTED) + if right: + _button(Vector2(center_x - 14, 250), "B") + _button(Vector2(center_x + 31, 264), "A") + _button(Vector2(center_x - 39,222),"X",14) + _button(Vector2(center_x + 5,216),"Y",14) + 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) + var pad := Vector2(center_x + 22, 252) + draw_rect(Rect2(pad - Vector2(12, 34), Vector2(24, 68)), DARK) + draw_rect(Rect2(pad - Vector2(34, 12), Vector2(68, 24)), DARK) + draw_line(pad + Vector2(-24, 0), pad + Vector2(24, 0), GREEN, 4.0, true) + draw_line(pad + Vector2(0, -24), pad + Vector2(0, 24), GREEN, 4.0, true) + draw_rect(Rect2(center_x - 36, 276, 24, 16), MUTED, false, 2.0) + _label("VIEW", Vector2(center_x - 49, 268), 15, MUTED) + + + func _button(at: Vector2, text: String, radius: float = 18.0) -> void: + draw_circle(at, radius, DARK) + draw_arc(at, radius, 0, TAU, 24, GREEN, 2.0, true) + _label(text, at + Vector2(-7, 7), 21, GREEN) + + + func _badge(text: String, at: Vector2, width: float = 32.0) -> void: + draw_rect(Rect2(at + Vector2(0, -20), Vector2(width, 28)), Color(0.08, 0.2, 0.19)) + draw_rect(Rect2(at + Vector2(0, -20), Vector2(width, 28)), MUTED, false, 1.0) + _label(text, at + Vector2(7, 1), 20, GREEN) + + + func _draw() -> void: + UI.frame(self,Rect2(2,2,996,596),DARK,UI.EDGE,14) + UI.text(self,"STEAM FRAME CONTROLS",Vector2(28,14),30,INK,"header") + UI.text(self,"BINDING DIAGRAM",Vector2(785,23),24,MUTED) + draw_line(Vector2(25, 58), Vector2(975, 58), MUTED, 2.0) + draw_line(Vector2(500, 78), Vector2(500, 470), MUTED, 1.0) + _label("LEFT / TABLE + NAVIGATION", Vector2(28, 91), 22, GREEN) + _label("RIGHT / POINT + ACT", Vector2(526, 91), 22, GREEN) + _controller(290, false) + _controller(660, true) + _callout("TRIGGER", ["Power page: + power", "Else: stations"], Vector2(28, 132), Vector2(301, 149), true) + _callout("STICK", ["Rotate / scale", "Click: recenter"], Vector2(28, 212), Vector2(273, 185), true) + _callout("GRIP", ["Move tabletop"], Vector2(28, 305), Vector2(222, 253), true) + _callout("BUMPER", ["Hold: 1-8 wheel", "Release: confirm"], Vector2(784, 119), Vector2(646, 128), false) + _callout("TRIGGER", ["Click / hold crew", "Drop / beam drag"], Vector2(784, 210), Vector2(694, 149), false) + _callout("GRIP", ["Hold for SHIFT"], Vector2(784, 305), Vector2(718, 253), false) + _label("DPAD / VIEW", Vector2(28, 369), 19, MUTED) + _leader(Vector2(312, 252), Vector2(431, 366), 434) + _badge("UP", Vector2(28, 394), 49) + _label("Shortcuts", Vector2(91, 394), 20) + _badge("VIEW",Vector2(296,394),72) + _label("Pause",Vector2(380,394),20) + _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("A", Vector2(526, 394)) + _label("Inspect rooms / wheel category", Vector2(568, 394), 20) + _badge("B", Vector2(526, 425)) + _label("Right-click / cancel grab or wheel", Vector2(568, 425), 19) + _label("STICK: wheel selection / click: CTRL", Vector2(526, 456), 20) + draw_line(Vector2(25, 480), Vector2(975, 480), MUTED, 2.0) + _label("SHIFT: add crew / depower weapons and systems", Vector2(28, 508), 19, GREEN) + _label("SHIFT + L trigger: save stations | CTRL: reverse autofire", Vector2(28, 534), 19) + _label("POWER PAGE: L trigger adds power / L bumper removes power", Vector2(28, 560), 19, GREEN) + _label("POWER PAGE: RIGHT X / Y select previous / next, or point at a system", Vector2(28, 586), 19) diff --git a/scripts/controller_panel.gd b/scripts/controller_panel.gd new file mode 100644 index 0000000..e5d0d18 --- /dev/null +++ b/scripts/controller_panel.gd @@ -0,0 +1,281 @@ +extends Node3D + +# The panel owns visuals only. Legacy navigation Area3Ds can retain their exact +# geometry; power cards are picked on this surface without physics collisions. +const UI = preload("res://scripts/ftl_ui_theme.gd") +# Native RenderPowerBoxes uses an orange battery outline, yellow bonus power, +# cyan ion locks and purple hacking. The allocated/battery/bonus fields are +# separate in ShipSystem; GetEffectivePower also includes native manning boosts. +const BATTERY := Color("e66e1e") +const BONUS := Color("fffa5a") +const ION_POWER := Color("85e7ed") +const HACKED_POWER := Color("cf46fd") +const SIZE := Vector2(0.92, 0.72) +const PIXELS := Vector2(1150, 900) +const SYSTEM_NAMES := {"shields":"SHIELDS", "engines":"ENGINES", "oxygen":"OXYGEN", "medbay":"MEDBAY", "clonebay":"CLONE BAY", "weapons":"WEAPONS", "drones":"DRONES", "teleporter":"TELEPORTER", "cloaking":"CLOAKING", "mind":"MIND CONTROL", "hacking":"HACKING", "artillery":"ARTILLERY", "battery":"BATTERY"} +const SYSTEM_ORDER := ["shields","engines","oxygen","medbay","clonebay","weapons","drones","teleporter","cloaking","mind","hacking","artillery","battery"] +var surface_size := SIZE +var viewport: SubViewport +var canvas: Control +var heading := "NAVIGATION" +var buttons: Array = [] +var power_rows: Array = [] +var reactor: Dictionary = {} +var selected_key := "" +var power_mode := false +var paused := false +var content_revision := 0 + + +func _ready() -> void: + viewport = SubViewport.new() + viewport.size = Vector2i(PIXELS) + viewport.transparent_bg = true + viewport.render_target_update_mode = SubViewport.UPDATE_ONCE + add_child(viewport) + canvas = PanelCanvas.new() + canvas.panel = self + canvas.size = PIXELS + canvas.texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST + viewport.add_child(canvas) + var display := MeshInstance3D.new() + var quad := QuadMesh.new() + quad.size = SIZE + display.mesh = quad + display.position.z = 0.012 + display.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF + var material := StandardMaterial3D.new() + material.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED + material.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA + material.no_depth_test = true + material.render_priority = 121 + material.texture_filter = BaseMaterial3D.TEXTURE_FILTER_NEAREST + material.albedo_texture = viewport.get_texture() + display.material_override = material + add_child(display) + + +func _redraw() -> void: + content_revision += 1 + if canvas != null: + canvas.queue_redraw() + viewport.render_target_update_mode = SubViewport.UPDATE_ONCE + + +func set_navigation(title: String, entries: Array, is_paused: bool = false) -> void: + if not power_mode and heading == title and buttons == entries and paused == is_paused: + return + heading = title + buttons = entries.duplicate(true) + paused = is_paused + power_mode = false + _redraw() + + +func set_power(player: Dictionary, is_paused: bool = false) -> void: + var rows := _power_rows(player) + var raw_power: Variant = player.get("reactor_status", player.get("reactor", {})) + var power: Dictionary = raw_power if raw_power is Dictionary else {} + if power_mode and rows == power_rows and power == reactor and paused == is_paused: + return + power_rows = rows + reactor = power.duplicate(true) + power_mode = true + paused = is_paused + heading = "SYSTEM POWER" + if selected_system().is_empty(): + selected_key = str(power_rows[0].get("key", "")) if not power_rows.is_empty() else "" + _redraw() + + +func _power_rows(player: Dictionary) -> Array: + var value: Variant = player.get("system_status", player.get("system_power", [])) + var result: Array = [] + if value is Array: + for row in value: + if row is Dictionary and row.get("powerable", true): + var copy: Dictionary = row.duplicate(true) + copy.key = str(copy.get("key", copy.get("name", ""))) + copy.name = str(copy.get("label", SYSTEM_NAMES.get(copy.key,copy.key.to_upper()))) + result.append(copy) + else: + # Compatibility with an older bridge: only real power details are used; + # boolean system-presence flags must never invent allocated power. + var systems: Dictionary = player.get("systems", {}) + for key in SYSTEM_ORDER: + var row: Variant = systems.get(key, null) + if row is Dictionary and row.get("powerable", true): + var copy: Dictionary = row.duplicate(true) + copy.key = key + copy.name = SYSTEM_NAMES.get(key, key.to_upper()) + result.append(copy) + return result + + +func select_system(key: String) -> void: + if selected_key == key: + return + for row in power_rows: + if str(row.get("key", "")) == key: + selected_key = key + _redraw() + return + + +func step_selection(direction: int) -> void: + if power_rows.is_empty(): + return + var index := 0 + for i in range(power_rows.size()): + if str(power_rows[i].key) == selected_key: + index = i + break + select_system(str(power_rows[posmod(index + direction,power_rows.size())].key)) + + +func selected_system() -> Dictionary: + for row in power_rows: + if str(row.get("key", "")) == selected_key: + return row + return {} + + +func power_card_rect(index: int) -> Rect2: + var count := maxi(1,power_rows.size()) + var rows_per_column := ceili(count / 2.0) + var height := minf(106.0, 642.0 / rows_per_column) + return Rect2(190 + floori(float(index) / rows_per_column) * 450, 140 + (index % rows_per_column) * height, 422, height - 10) + + +func ray_hit(from: Vector3, direction: Vector3) -> Dictionary: + if not is_visible_in_tree(): + return {} + var start := to_local(from) + var ray := to_local(from + direction) - start + if absf(ray.z) < 0.00001: + return {} + var distance := (0.012 - start.z) / ray.z + if distance <= 0: + return {} + var point := start + distance * ray + var pixel := Vector2(point.x / SIZE.x + 0.5,0.5 - point.y / SIZE.y) * PIXELS + if not Rect2(Vector2.ZERO,PIXELS).has_point(pixel): + return {} + var hit := {"position":to_global(point), "system_key":""} + if power_mode: + for i in range(power_rows.size()): + if power_card_rect(i).has_point(pixel): + hit.system_key = str(power_rows[i].key) + break + return hit + + +class PanelCanvas extends Control: + var panel: Node3D + var icons: Dictionary = {} + var base_path := "" + + + func _ready() -> void: + base_path = ProjectSettings.globalize_path("res://local_game_data") if OS.has_feature("editor") else OS.get_executable_path().get_base_dir().path_join("local_game_data") + + + func _icon(key: String) -> Texture2D: + if not icons.has(key): + var path := base_path.path_join("ui/img/icons/s_%s.png" % key) + icons[key] = ImageTexture.create_from_image(Image.load_from_file(path)) if FileAccess.file_exists(path) else null + return icons[key] + + + func _draw() -> void: + UI.frame(self,Rect2(3,3,1144,894),UI.DARK,UI.EDGE,5) + UI.text(self,panel.heading,Vector2(32,29),32,UI.INK,"header") + draw_line(Vector2(28,85),Vector2(1122,85),UI.MUTED,1) + if panel.paused: + UI.centered_text(self,"PAUSED",Vector2(1024,46),25,UI.GREEN,"header") + if panel.power_mode: + _draw_power() + else: + var low_buttons := false + for item in panel.buttons: + var point: Vector2 = item.get("position",Vector2.ZERO) + low_buttons = low_buttons or point.y < -.25 + var center := Vector2(point.x / SIZE.x + .5,.5 - point.y / SIZE.y) * PIXELS + var rect := Rect2(center - Vector2(250,41.875),Vector2(500,83.75)) + UI.button(self,rect,str(item.get("label","")),item.get("enabled",true)) + if low_buttons: + UI.centered_text(self,"DPAD L/R: SCREEN DOWN: TACTICAL VIEW: PAUSE",Vector2(575,877),22,UI.MUTED) + else: + draw_line(Vector2(38,829),Vector2(1112,829),UI.MUTED,1) + UI.centered_text(self,"DPAD L/R: SCREEN DOWN: TACTICAL VIEW: PAUSE",Vector2(575,850),24,UI.MUTED) + UI.centered_text(self,"LEFT TRIGGER: STATIONS RIGHT BUMPER: ACTION WHEEL",Vector2(575,877),22,UI.INK) + + + func _draw_power() -> void: + UI.text(self,"REACTOR",Vector2(31,108),27,UI.INK) + var total := clampi(int(panel.reactor.get("total",panel.reactor.get("max",0))),0,50) + var available := clampi(int(panel.reactor.get("available",panel.reactor.get("current",0))),0,total) + UI.frame(self,Rect2(41,145,103,610),UI.PANEL,UI.EDGE,9) + var pitch := minf(20,562.0 / maxi(1,total)) + for cell in range(total): + var rect := Rect2(57,731 - cell * pitch,71,pitch - 3) + draw_rect(rect,UI.GREEN if cell < available else UI.DARK) + draw_rect(rect,UI.EDGE,false,1) + UI.centered_text(self,"%d/%d" % [available,total],Vector2(94,779),32,UI.GREEN) + UI.centered_text(self,"FREE",Vector2(94,809),22,UI.MUTED) + for i in range(panel.power_rows.size()): + var row: Dictionary = panel.power_rows[i] + var rect: Rect2 = panel.power_card_rect(i) + var active: bool = str(row.key) == panel.selected_key + UI.frame(self,rect,UI.SELECTED if active else UI.PANEL,UI.GOLD if active else UI.EDGE,9) + var texture := _icon(str(row.key)) + if texture != null: + draw_texture_rect(texture,Rect2(rect.position + Vector2(15,13),Vector2(42,42)),false,UI.GOLD if active else UI.INK) + else: + draw_rect(Rect2(rect.position + Vector2(18,18),Vector2(34,34)),UI.MUTED,false,2) + UI.centered_text(self,str(row.key).substr(0,1),rect.position + Vector2(35,35),30,UI.INK) + UI.text(self,str(row.name),rect.position + Vector2(68,12),30,UI.GOLD if active else UI.INK) + var capacity := clampi(int(row.get("max_power",row.get("max",0))),0,16) + var allocated := clampi(int(row.get("allocated",row.get("power",0))),0,capacity) + var battery := clampi(int(row.get("battery_power",0)),0,capacity) + var bonus := clampi(int(row.get("bonus_power",0)),0,capacity) + var effective := maxi(0,int(row.get("effective_power",allocated + battery + bonus))) + var damaged := clampi(int(row.get("damaged",0)),0,capacity) + var cap := clampi(int(row.get("power_cap",capacity)),0,capacity) + var ionized := bool(row.get("ionized",false)) + var locked := bool(row.get("locked",false)) + var hacked := int(row.get("hacked",0)) > 1 + var bar_y := rect.end.y - 30 + var cell_width := minf(22,(250.0 - maxi(0,capacity - 1) * 3) / maxi(1,capacity)) + for cell in range(capacity): + var bar := Rect2(rect.position.x + 68 + cell * (cell_width + 3),bar_y,cell_width,15) + var color := UI.DARK + var outline := UI.EDGE + var battery_cell := cell >= allocated and cell < allocated + battery + if cell < allocated: + color = HACKED_POWER if hacked else ION_POWER if ionized else UI.INK if locked else UI.GREEN + elif battery_cell: + outline = BATTERY + elif cell < allocated + battery + bonus: + color = BONUS + elif cell >= capacity - damaged: + color = UI.RED + elif cell >= cap: + outline = ION_POWER + draw_rect(bar,color) + if battery_cell: + draw_rect(bar.grow(-3),UI.GREEN) + draw_rect(bar,outline,false,1) + # Keep the native effective total; do not count battery twice or debit + # reactor availability for Zoltan power. Raw selected rows are untouched. + UI.text(self,"%d/%d" % [effective,capacity],Vector2(rect.end.x - 75,bar_y - 2),22,UI.INK) + if hacked or ionized or locked: + UI.text(self,"HACK" if hacked else "LOCK",Vector2(rect.end.x - 75,rect.position.y + 16),20,HACKED_POWER if hacked else ION_POWER if ionized else UI.INK) + if panel.power_rows.is_empty(): + UI.centered_text(self,"WAITING FOR LIVE SYSTEM STATUS",Vector2(661,440),30,UI.MUTED) + for source in [[250,"REACTOR",UI.GREEN],[445,"BATTERY",BATTERY],[640,"ZOLTAN",BONUS],[826,"DAMAGED",UI.RED]]: + draw_rect(Rect2(int(source[0]),791,12,12),source[2]) + UI.text(self,str(source[1]),Vector2(int(source[0]) + 22,788),22,source[2]) + draw_line(Vector2(185,814),Vector2(1107,814),UI.MUTED,1) + UI.centered_text(self,"L TRIGGER: + POWER L BUMPER: - POWER",Vector2(661,842),28,UI.INK) + UI.centered_text(self,"RIGHT X/Y: PREV/NEXT POINT: SELECT SYSTEM",Vector2(661,873),28,UI.GOLD) diff --git a/scripts/enemy_hull_bar.gd b/scripts/enemy_hull_bar.gd new file mode 100644 index 0000000..1296017 --- /dev/null +++ b/scripts/enemy_hull_bar.gd @@ -0,0 +1,85 @@ +extends Node3D + +const UI = preload("res://scripts/ftl_ui_theme.gd") +const WIDTH := 0.64 +const HEIGHT := 0.10 +const PIXELS := Vector2i(770,130) +const HULL_COLOR := Color("85ff79") +var viewport: SubViewport +var canvas: Control +var plate: MeshInstance3D +var hull := 0 +var hull_max := 0 +var ship_label := "HOSTILE" +var signature := "" + + +func build() -> void: + viewport = SubViewport.new() + viewport.size = PIXELS + viewport.transparent_bg = true + viewport.render_target_update_mode = SubViewport.UPDATE_ONCE + add_child(viewport) + canvas = Control.new() + canvas.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + canvas.texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST + canvas.draw.connect(_draw) + viewport.add_child(canvas) + plate = MeshInstance3D.new() + var quad := QuadMesh.new() + quad.size = Vector2(WIDTH, HEIGHT) + plate.mesh = quad + var material := StandardMaterial3D.new() + material.albedo_texture = viewport.get_texture() + material.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA + material.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED + material.texture_filter = BaseMaterial3D.TEXTURE_FILTER_NEAREST + material.cull_mode = BaseMaterial3D.CULL_DISABLED + plate.material_override = material + plate.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF + add_child(plate) + visible = false + + +func set_state(data: Dictionary) -> void: + hull = maxi(0, int(data.get("hull", 0))) + hull_max = maxi(0, int(data.get("hull_max", 0))) + ship_label = str(data.get("name", "HOSTILE")).to_upper() + if ship_label.is_empty(): + ship_label = "HOSTILE" + visible = not data.is_empty() and hull_max > 0 and not bool(data.get("destroyed", false)) + var next := "%d:%d:%s" % [hull, hull_max, ship_label] + if next == signature: + return + signature = next + canvas.queue_redraw() + viewport.render_target_update_mode = SubViewport.UPDATE_ONCE + + +func face_viewer(viewer: Vector3) -> void: + if visible and global_position.distance_squared_to(viewer) > 0.0001: + look_at(viewer, Vector3.UP, true) + + +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 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. + 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) + canvas.draw_texture_rect(frame,Rect2(0,0,770,130),false) + canvas.draw_texture_rect(label,Rect2(18,0,104,56),false) + else: + # Missing owned art still leaves a readable small bar during setup. + 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")) + 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) diff --git a/scripts/ftl_ui_assets.gd b/scripts/ftl_ui_assets.gd new file mode 100644 index 0000000..30adf5f --- /dev/null +++ b/scripts/ftl_ui_assets.gd @@ -0,0 +1,122 @@ +extends RefCounted + +# Owner-local SIL bitmap fonts. Preserve the original atlas, baseline, glyph +# bearings and 1/256-pixel advance; no editor import or installed font is needed. +const FONT_NAMES := {"body":"JustinFont10.font", "header":"c&c.font", "bold":"JustinFont11Bold.font"} +static var fonts: Dictionary = {} +static var textures: Dictionary = {} + + +static func data_path() -> String: + if OS.has_feature("editor") or OS.get_cmdline_user_args().has("--desktop"): + return ProjectSettings.globalize_path("res://local_game_data") + return OS.get_executable_path().get_base_dir().path_join("local_game_data") + + +static func font(role: String = "body") -> Font: + var name: String = FONT_NAMES.get(role,FONT_NAMES.body) + if not fonts.has(name): + var native := load_font(data_path().path_join("fonts").path_join(name)) + fonts[name] = native if native != null else ThemeDB.fallback_font + return fonts[name] + + +static func texture(relative_path: String) -> Texture2D: + if not textures.has(relative_path): + var path := data_path().path_join("ui").path_join(relative_path) + var image := Image.load_from_file(path) if FileAccess.file_exists(path) else null + textures[relative_path] = ImageTexture.create_from_image(image) if image != null and not image.is_empty() else null + return textures[relative_path] + + +static func _be16(bytes: PackedByteArray, at: int) -> int: + return (int(bytes[at]) << 8) | int(bytes[at + 1]) + + +static func _be32(bytes: PackedByteArray, at: int) -> int: + return (_be16(bytes,at) << 16) | _be16(bytes,at + 2) + + +static func _signed(value: int, bits: int) -> int: + return value - (1 << bits) if value & (1 << (bits - 1)) else value + + +static func load_font(path: String) -> FontFile: + if not FileAccess.file_exists(path): return null + var bytes := FileAccess.get_file_as_bytes(path) + if bytes.size() < 24 or bytes.slice(0,4).get_string_from_ascii() != "FONT" or bytes[4] != 1: return null + var height := int(bytes[5]) + var baseline := int(bytes[6]) + var info := _be32(bytes,8) + var count := _be16(bytes,12) + var stride := _be16(bytes,14) + var tex := _be32(bytes,16) + var tex_length := _be32(bytes,20) + if height < 1 or baseline > height or stride != 16 or count < 1 or info < 24 or info + count * stride > bytes.size(): return null + if tex < 24 or tex_length < 32 or tex + tex_length > bytes.size(): return null + # FTL's western fonts contain a SIL v2, uncompressed A8 texture (0x40). + if bytes.slice(tex,tex + 4).get_string_from_ascii() != "TEX\n" or bytes[tex + 4] != 2 or bytes[tex + 5] != 0x40: return null + var width := _be16(bytes,tex + 8) + var atlas_height := _be16(bytes,tex + 10) + var pixels := _be32(bytes,tex + 16) + var pixel_count := _be32(bytes,tex + 20) + if width < 1 or atlas_height < 1 or width > 2048 or atlas_height > 2048 or pixel_count != width * atlas_height or pixels < 32 or pixels + pixel_count > tex_length: return null + # Validate all glyph bounds before mutating the font cache. + for i in range(count): + var offset := info + i * stride + if _be32(bytes,offset) > 0x10ffff or _be16(bytes,offset + 4) + int(bytes[offset + 8]) > width or _be16(bytes,offset + 6) + int(bytes[offset + 9]) > atlas_height: return null + var rgba := PackedByteArray() + rgba.resize(pixel_count * 4) + for i in range(pixel_count): + rgba[i * 4] = 255 + rgba[i * 4 + 1] = 255 + rgba[i * 4 + 2] = 255 + rgba[i * 4 + 3] = bytes[tex + pixels + i] + var source_image := Image.create_from_data(width,atlas_height,false,Image.FORMAT_RGBA8,rgba) + # Godot's 3D glyph quads need transparent margins around a narrow stroke. + # Repack original pixels without changing bearings/advances; a stroke at the + # right edge of a 2px I/i/l glyph otherwise disappears in Label3D sampling. + const PADDING := 2 + var atlas_width := 512 + var uv_rects: Array[Rect2i] = [] + var cursor := Vector2i.ZERO + var row_height := 0 + for i in range(count): + var offset := info + i * stride + var padded := Vector2i(int(bytes[offset + 8]),int(bytes[offset + 9])) + Vector2i.ONE * (PADDING * 2) + if cursor.x + padded.x > atlas_width: + cursor = Vector2i(0,cursor.y + row_height) + row_height = 0 + uv_rects.append(Rect2i(cursor,padded)) + cursor.x += padded.x + row_height = maxi(row_height,padded.y) + var padded_height := 1 + while padded_height < cursor.y + row_height: padded_height <<= 1 + var atlas := Image.create(atlas_width,padded_height,false,Image.FORMAT_RGBA8) + atlas.fill(Color(1,1,1,0)) + for i in range(count): + var offset := info + i * stride + var original := Rect2i(_be16(bytes,offset + 4),_be16(bytes,offset + 6),int(bytes[offset + 8]),int(bytes[offset + 9])) + if original.size.x > 0 and original.size.y > 0: + atlas.blit_rect(source_image,original,uv_rects[i].position + Vector2i.ONE * PADDING) + var result := FontFile.new() + result.font_name = path.get_file().get_basename() + result.fixed_size = height + result.fixed_size_scale_mode = TextServer.FIXED_SIZE_SCALE_ENABLED + result.antialiasing = TextServer.FONT_ANTIALIASING_NONE + result.allow_system_fallback = false + result.set_cache_ascent(0,height,baseline) + result.set_cache_descent(0,height,height - baseline) + result.set_texture_image(0,Vector2i(height,0),0,atlas) + for i in range(count): + var offset := info + i * stride + var codepoint := _be32(bytes,offset) + var glyph_size := Vector2(int(bytes[offset + 8]),int(bytes[offset + 9])) + var pre := _signed(_be16(bytes,offset + 12),16) / 256.0 + var post := _signed(_be16(bytes,offset + 14),16) / 256.0 + result.set_glyph_advance(0,height,codepoint,Vector2(pre + glyph_size.x + post,0)) + result.set_glyph_offset(0,Vector2i(height,0),codepoint,Vector2(pre - PADDING,-_signed(int(bytes[offset + 10]),8) - PADDING)) + result.set_glyph_size(0,Vector2i(height,0),codepoint,glyph_size + Vector2.ONE * (PADDING * 2)) + result.set_glyph_uv_rect(0,Vector2i(height,0),codepoint,Rect2(uv_rects[i])) + result.set_glyph_texture_idx(0,Vector2i(height,0),codepoint,0) + return result diff --git a/scripts/ftl_ui_theme.gd b/scripts/ftl_ui_theme.gd new file mode 100644 index 0000000..ad0e999 --- /dev/null +++ b/scripts/ftl_ui_theme.gd @@ -0,0 +1,47 @@ +extends RefCounted + +const ASSETS = preload("res://scripts/ftl_ui_assets.gd") +# Native FTL uses near-black panels, pale outlines and restrained green/gold. +const INK := Color("e0ead4") +const EDGE := Color("a2b5a5") +const GREEN := Color("7fff74") +const MUTED := Color("70857d") +const DARK := Color("081012") +const PANEL := Color("102023") +const SELECTED := Color("20352c") +const GOLD := Color("ffe89a") +const RED := Color("ed6260") +const ION := Color("7bb8ff") + + +static func text_size(value: String, font_size: int, role: String = "body") -> Vector2: + var font := ASSETS.font(role) + return Vector2(font.get_string_size(value,HORIZONTAL_ALIGNMENT_LEFT,-1,font_size).x,font.get_height(font_size)) + + +static func text(canvas: CanvasItem, value: String, at: Vector2, font_size: int = 28, color: Color = INK, role: String = "body") -> void: + var font := ASSETS.font(role) + canvas.draw_string(font,(at + Vector2(0,font.get_ascent(font_size))).round(),value,HORIZONTAL_ALIGNMENT_LEFT,-1,font_size,color) + + +static func centered_text(canvas: CanvasItem, value: String, center: Vector2, font_size: int = 28, color: Color = INK, role: String = "body") -> void: + text(canvas,value,center - text_size(value,font_size,role) * .5,font_size,color,role) + + +static func clipped_rect(rect: Rect2, cut: float = 5.0) -> PackedVector2Array: + var p := rect.position + var q := rect.end + return PackedVector2Array([Vector2(p.x + cut,p.y),Vector2(q.x - cut,p.y),Vector2(q.x,p.y + cut),Vector2(q.x,q.y - cut),Vector2(q.x - cut,q.y),Vector2(p.x + cut,q.y),Vector2(p.x,q.y - cut),Vector2(p.x,p.y + cut)]) + + +static func frame(canvas: CanvasItem, rect: Rect2, fill: Color = PANEL, edge: Color = EDGE, cut: float = 5.0) -> void: + var polygon := clipped_rect(rect,minf(cut,5.0)) + canvas.draw_colored_polygon(polygon,fill) + polygon.append(polygon[0]) + canvas.draw_polyline(polygon,edge,1.5,false) + + +static func button(canvas: CanvasItem, rect: Rect2, label: String, enabled: bool = true, highlighted: bool = false) -> void: + frame(canvas,rect,SELECTED if highlighted else PANEL,GOLD if highlighted else EDGE if enabled else MUTED,4) + var size_px := 34 if label.length() <= 20 else 28 + centered_text(canvas,label,rect.get_center(),size_px,INK if enabled else MUTED) diff --git a/scripts/hud_canvas.gd b/scripts/hud_canvas.gd new file mode 100644 index 0000000..8a332f0 --- /dev/null +++ b/scripts/hud_canvas.gd @@ -0,0 +1,205 @@ +extends Control + +signal texture_changed + +var state: Dictionary = {} +var textures: Dictionary = {} +var original_frame: Texture2D +var frame_image: Image +var frame_signature := "" +var frame_file := "hud_frame.png" +var use_original_frame := true +var source_rect := Rect2(0, 0, 1280, 720) +var base_path := "" +var font: Font = ThemeDB.fallback_font +var received_frames := 0 +var capture_time_unix := 0.0 +const NATIVE_SIZE := Vector2(1280, 720) +const RAW_HEADER_SIZE := 36 + + +func set_state(value: Dictionary) -> void: + state = value + if original_frame == null: + request_redraw() + + +func request_redraw() -> void: + queue_redraw() + texture_changed.emit() + + +func poll_original_frame() -> void: + if not use_original_frame: + return + var path := base_path.path_join(frame_file) + var raw_path := path.get_basename() + ".rgba" + if FileAccess.file_exists(raw_path): + _poll_raw_frame(raw_path) + return + if not FileAccess.file_exists(path): + var had_frame := original_frame != null + original_frame = null + frame_image = null + frame_signature = "" + if had_frame: + request_redraw() + return + var source := FileAccess.open(path, FileAccess.READ) + if source == null: + return + var bytes := source.get_buffer(source.get_length()) + source.close() + var digest := HashingContext.new() + digest.start(HashingContext.HASH_SHA256) + digest.update(bytes) + var signature := frame_file + digest.finish().hex_encode() + if signature != frame_signature: + var image := Image.new() + if image.load_png_from_buffer(bytes) == OK and not image.is_empty(): + _accept_frame(image, signature) + + +func _poll_raw_frame(path: String) -> void: + var source := FileAccess.open(path, FileAccess.READ) + if source == null: + return + # Header-only polling avoids reading, hashing and decoding an unchanged image. + var header := source.get_buffer(RAW_HEADER_SIZE) + if header.size() != RAW_HEADER_SIZE or header.slice(0, 4).get_string_from_ascii() != "FVR1" or header.decode_u32(4) != 1: + source.close() + return + var width := int(header.decode_u32(8)) + var height := int(header.decode_u32(12)) + if width < 1 or height < 1 or width > 2560 or height > 1440 or header.decode_u32(16) != 4 or source.get_length() != RAW_HEADER_SIZE + width * height * 4: + source.close() + return + var signature := frame_file + header.hex_encode() + if signature == frame_signature: + source.close() + return + var bytes := source.get_buffer(width * height * 4) + source.close() + if bytes.size() != width * height * 4: + return + capture_time_unix = header.decode_double(28) + _accept_frame(Image.create_from_data(width, height, false, Image.FORMAT_RGBA8, bytes), signature) + + +func _accept_frame(image: Image, signature: String) -> void: + frame_image = image + if original_frame is ImageTexture and Vector2i(original_frame.get_size()) == image.get_size(): + original_frame.update(image) + else: + original_frame = ImageTexture.create_from_image(image) + frame_signature = signature + received_frames += 1 + request_redraw() + + +func native_alpha_at(pixel: Vector2i) -> float: + if frame_image == null: + return 0.0 + var image_size := frame_image.get_size() + var scaled := Vector2(pixel) * Vector2(image_size) / NATIVE_SIZE + var sample := Vector2i(clampi(floori(scaled.x), 0, image_size.x - 1), clampi(floori(scaled.y), 0, image_size.y - 1)) + return frame_image.get_pixelv(sample).a + + +func native_used_rect() -> Rect2i: + if frame_image == null: + return Rect2i() + var used := frame_image.get_used_rect() + var scale := NATIVE_SIZE / Vector2(frame_image.get_size()) + var first := Vector2(used.position) * scale + var last := Vector2(used.end) * scale + return Rect2i(Vector2i(floori(first.x), floori(first.y)), Vector2i(ceili(last.x), ceili(last.y)) - Vector2i(floori(first.x), floori(first.y))) + + +func _texture(path: String) -> Texture2D: + if textures.has(path): + return textures[path] + var file := base_path.path_join("ui/img").path_join(path) + if not FileAccess.file_exists(file): + return null + var image := Image.load_from_file(file) + var texture := ImageTexture.create_from_image(image) + textures[path] = texture + return texture + + +func _art(path: String, point: Vector2) -> void: + var texture := _texture(path) + if texture != null: + draw_texture(texture, point) + + +func _text(value: String, point: Vector2, size_px: int = 17, color: Color = Color(0.92, 0.97, 0.87)) -> void: + draw_string(font, point, value, HORIZONTAL_ALIGNMENT_LEFT, -1.0, size_px, color) + + +func _draw() -> void: + if original_frame != null: + # The live bridge can provide the original game's transparent UI render target. + var transport_rect := Rect2(source_rect.position * original_frame.get_size() / NATIVE_SIZE, source_rect.size * original_frame.get_size() / NATIVE_SIZE) + draw_texture_rect_region(original_frame, Rect2(Vector2.ZERO, size), transport_rect) + return + _art("statusUI/top_hull.png", Vector2(5, 0)) + _art("statusUI/top_hull_label.png", Vector2(5, 0)) + _text("HULL", Vector2(12, 22), 18, Color(0.04, 0.12, 0.13)) + var hull := int(state.get("hull", 30)) + var hull_max := maxi(1, int(state.get("hull_max", 30))) + for i in range(30): + var color := Color(0.48, 1.0, 0.47) if i < roundi(30.0 * hull / hull_max) else Color(0.11, 0.18, 0.16) + draw_rect(Rect2(19 + i * 11.7, 32, 10, 14), color) + _art("statusUI/top_scrap.png", Vector2(393, 0)) + _text(str(state.get("scrap", 52)), Vector2(444, 34)) + _art("statusUI/top_shields4_on.png", Vector2(5, 57)) + for i in range(clampi(int(state.get("shield", 2)), 0, 4)): + _art("statusUI/top_shieldsquare1_on.png", Vector2(31 + i * 23, 64)) + for entry in [["fuel", 140, 17], ["missiles", 220, 8], ["drones", 300, 2]]: + _art("statusUI/top_%s_on.png" % entry[0], Vector2(entry[1], 57)) + _text(str(state.get(entry[0], entry[2])), Vector2(entry[1] + 42, 84)) + _art("statusUI/top_evade_oxygen.png", Vector2(5, 106)) + _text("%d%%" % int(state.get("evasion", 25)), Vector2(48, 128), 15) + _text("%d%%" % int(state.get("oxygen", 100)), Vector2(48, 152), 15) + var crew: Array = state.get("crew", [{"name": "Crew 1", "health": 100}, {"name": "Crew 2", "health": 100}, {"name": "Crew 3", "health": 100}]) + for i in range(crew.size()): + var y := 178 + i * 31 + draw_rect(Rect2(8, y, 108, 26), Color(0.06, 0.13, 0.14, 0.9)) + draw_rect(Rect2(8, y, 108, 26), Color(0.58, 0.68, 0.6), false) + draw_circle(Vector2(21, y + 10), 6, Color(0.6, 0.9, 0.34)) + _text(str(crew[i].get("name", "Crew")), Vector2(34, y + 16), 14) + draw_rect(Rect2(13, y + 21, 94 * clampf(float(crew[i].get("health", 100)) / 100.0, 0, 1), 3), Color(0.36, 0.88, 0.33)) + var reactor := int(state.get("reactor", 8)) + for i in range(25): + draw_rect(Rect2(20, 682 - i * 6, 20, 4), Color(0.53, 0.97, 0.49) if i < reactor else Color(0.08, 0.1, 0.11)) + draw_rect(Rect2(20, 682 - i * 6, 20, 4), Color(0.55, 0.61, 0.6), false) + var systems: Dictionary = state.get("systems", {"shields": 2, "engines": 2, "medbay": 1, "oxygen": 1, "weapons": 3, "drones": 0}) + var system_order := ["shields", "engines", "medbay", "oxygen", "weapons", "drones"] + for i in range(system_order.size()): + var x := 62 + i * 43 + var system_name: String = system_order[i] + _art("icons/s_%s_green1.png" % system_name, Vector2(x, 666)) + _art("systemUI/button_default_base.png", Vector2(x, 592)) + for cell in range(int(systems.get(system_name, 0))): + draw_rect(Rect2(x + 12, 651 - cell * 9, 17, 7), Color(0.4, 0.94, 0.35)) + _art("box_weapons_bottom4.png", Vector2(341, 616)) + _art("box_weapons_bottom_label.png", Vector2(341, 680)) + _text("WEAPONS", Vector2(352, 705), 17, Color(0.07, 0.17, 0.17)) + var weapons: Array = state.get("weapons", [{"name": "Burst Laser II", "charge": 1.0}, {"name": "Artemis", "charge": 1.0}]) + for i in range(mini(4, weapons.size())): + var x := 350 + i * 99 + draw_rect(Rect2(x, 624, 93, 46), Color(0.02, 0.08, 0.06, 0.88)) + draw_rect(Rect2(x, 624, 93, 46), Color(0.75, 0.87, 0.78), false) + draw_rect(Rect2(x + 3, 628, 5, 37 * clampf(float(weapons[i].get("charge", 0)), 0, 1)), Color(0.38, 0.97, 0.38)) + _text(str(weapons[i].get("name", "Weapon")), Vector2(x + 12, 647), 11) + _text(str(i + 1), Vector2(x + 12, 665), 13) + _art("box_weapons_autofire_base.png", Vector2(663, 678)) + _text("AUTOFIRE", Vector2(676, 698), 12, Color(0.12, 0.2, 0.12)) + _art("box_subsystems4.png", Vector2(1080, 678)) + for i in range(3): + _art("icons/s_%s_green1.png" % ["pilot", "sensors", "doors"][i], Vector2(1083 + i * 43, 646)) + _text("SUBSYSTEMS", Vector2(1089, 703), 15, Color(0.07, 0.17, 0.17)) + if bool(state.get("paused", false)): + _art("Text_pause1.png", Vector2(536, 523)) diff --git a/scripts/hud_canvas.gd.uid b/scripts/hud_canvas.gd.uid new file mode 100644 index 0000000..283515d --- /dev/null +++ b/scripts/hud_canvas.gd.uid @@ -0,0 +1 @@ +uid://dcei0upg4ewds diff --git a/scripts/hud_surface.gd b/scripts/hud_surface.gd new file mode 100644 index 0000000..2c4e3da --- /dev/null +++ b/scripts/hud_surface.gd @@ -0,0 +1,111 @@ +extends Node3D + +const HudCanvas = preload("res://scripts/hud_canvas.gd") +var canvas: Control +var viewport: SubViewport +var timer := 0.0 +var frame_file := "hud_frame.png" +var render_order := 0 +var source_rect := Rect2(0, 0, 1280, 720) +var surface_size := Vector2(2.85, 1.603125) +var quad: QuadMesh +var mesh_instance: MeshInstance3D +const SURFACE_SIZE := Vector2(2.85, 1.603125) + + +func _ready() -> void: + viewport = SubViewport.new() + viewport.size = Vector2i(1280, 720) + viewport.size_2d_override = Vector2i(1280, 720) + viewport.size_2d_override_stretch = true + viewport.transparent_bg = true + viewport.render_target_update_mode = SubViewport.UPDATE_ONCE + add_child(viewport) + canvas = HudCanvas.new() + canvas.frame_file = frame_file + canvas.source_rect = source_rect + canvas.size = Vector2(1280, 720) + canvas.texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST + canvas.base_path = ProjectSettings.globalize_path("res://local_game_data") if OS.has_feature("editor") else OS.get_executable_path().get_base_dir().path_join("local_game_data") + viewport.add_child(canvas) + canvas.texture_changed.connect(_request_update) + var mesh := MeshInstance3D.new() + mesh_instance = mesh + quad = QuadMesh.new() + quad.size = surface_size + mesh.mesh = quad + var material := StandardMaterial3D.new() + material.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED + material.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA + material.no_depth_test = true + material.render_priority = render_order + material.albedo_texture = viewport.get_texture() + mesh.material_override = material + add_child(mesh) + + +func set_state(state: Dictionary) -> void: + canvas.set_state(state) + + +func set_render_order(value: int) -> void: + render_order = value + if mesh_instance != null: + mesh_instance.material_override.render_priority = value + + +func _request_update() -> void: + # Tactical is reduced; the same stream carries the full-resolution jump + # map, whose small labels must retain their native rendering resolution. + var dimensions := Vector2i(1280, 720) + if canvas.frame_file == "screen_frame.png" and canvas.frame_image != null: + dimensions = canvas.frame_image.get_size() + if viewport.size != dimensions: + viewport.size = dimensions + viewport.render_target_update_mode = SubViewport.UPDATE_ONCE + + +func ray_pixel(from: Vector3, direction: Vector3) -> Variant: + if not is_visible_in_tree() or canvas.frame_image == null: + return null + var local_from := to_local(from) + var local_end := to_local(from + direction) + var local_direction := local_end - local_from + if absf(local_direction.z) < 0.00001: + return null + var distance := -local_from.z / local_direction.z + if distance <= 0.0: + return null + var point := local_from + distance * local_direction + var uv := Vector2(point.x / surface_size.x + 0.5, 0.5 - point.y / surface_size.y) + if uv.x < 0.0 or uv.x >= 1.0 or uv.y < 0.0 or uv.y >= 1.0: + return null + var native := source_rect.position + uv * source_rect.size + var pixel := Vector2i(clampi(roundi(native.x), maxi(0, ceili(source_rect.position.x)), mini(1279, ceili(source_rect.end.x) - 1)), clampi(roundi(native.y), maxi(0, ceili(source_rect.position.y)), mini(719, ceili(source_rect.end.y) - 1))) + if canvas.native_alpha_at(pixel) < 0.1: + return null + return pixel + + +func pixel_world(pixel: Vector2i) -> Vector3: + var uv := (Vector2(pixel) - source_rect.position) / source_rect.size + return to_global(Vector3((uv.x - 0.5) * surface_size.x, (0.5 - uv.y) * surface_size.y, 0.01)) + + +func set_crop(rect: Rect2, dimensions: Vector2) -> void: + if source_rect == rect and surface_size == dimensions: + return + source_rect = rect + surface_size = dimensions + quad.size = dimensions + canvas.source_rect = rect + canvas.request_redraw() + + +func _process(delta: float) -> void: + if not is_visible_in_tree(): + return + timer += delta + if timer >= 1.0 / 60.0: + timer = fmod(timer, 1.0 / 60.0) + canvas.poll_original_frame() diff --git a/scripts/hud_surface.gd.uid b/scripts/hud_surface.gd.uid new file mode 100644 index 0000000..2d8e7fb --- /dev/null +++ b/scripts/hud_surface.gd.uid @@ -0,0 +1 @@ +uid://rs7vf2puuej1 diff --git a/scripts/main.gd b/scripts/main.gd new file mode 100644 index 0000000..3f47351 --- /dev/null +++ b/scripts/main.gd @@ -0,0 +1,1596 @@ +extends Node3D + +const ShipModel = preload("res://scripts/ship_model.gd") +const HudSurface = preload("res://scripts/hud_surface.gd") +const ShortcutWheel = preload("res://scripts/shortcut_wheel.gd") +const CombatEffects = preload("res://scripts/combat_effects.gd") +const SpaceEnvironment = preload("res://scripts/space_environment.gd") +const RenameKeyboard = preload("res://scripts/rename_keyboard.gd") +const ControllerHelp = preload("res://scripts/controller_help.gd") +const ControllerPanel = preload("res://scripts/controller_panel.gd") +const PoseFilter = preload("res://scripts/pose_filter.gd") +const EnemyHullBar = preload("res://scripts/enemy_hull_bar.gd") +const UiAssets = preload("res://scripts/ftl_ui_assets.gd") + +var xr_active := false +var origin: XROrigin3D +var camera: XRCamera3D +var left_hand: XRController3D +var right_hand: XRController3D +var player_ship: Node3D +var enemy_ship: Node3D +var nav_panel: Node3D +var hud_surface: Node3D +var combat_effects: Node3D +var hud_state: Dictionary = {} +var seen_shots: Dictionary = {} +var right_laser: MeshInstance3D +var hover_marker: MeshInstance3D +var hazard_root: Node3D +var space_environment: Node3D +var battle := true +var paused := false +var hazard := "clear" +var crew_selected := false +var weapon_selected := true +var trigger_was_down := false +var pause_was_down := false +var cancel_was_down := false +var last_command := "Select a crew marker or target an enemy room" +var elapsed := 0.0 +var state_poll_elapsed := 0.0 +var state_signature := "" +var bridge_connected := false +var hull_current := 30 +var hull_max := 30 +var shield_level := 2 +var reactor_power := 8 +var tabletop_root: Node3D +var map_surface: Node3D +var crew_id_selected := "" +var crew_ship_selected := "player" +var held_surface: Node3D +var held_pixel := Vector2i.ZERO +var beam_drag := false +var inspect_was_down := false +var tactical_was_down := false +var grip_last_position := Vector3.ZERO +var gripping := false +var bridge_session := "" +var hover_elapsed := 0.0 +var demo_mode := false +var recenter_was_down := false +var last_hover_time := 0.0 +var shift_down := false +var control_down := false +var nav_page := "navigation" +var nav_buttons: Array[Area3D] = [] +var nav_title: Label3D +var help_label: Node3D +var pause_label: Label3D +var rename_keyboard: Node3D +var keyboard_press := false +var target_room_label: Label3D +var pointed_room := -1 +var pointed_ship := "" +var panel_theme: Node3D +var enemy_hull_bar: Node3D +var panel_filter := PoseFilter.new() +var ray_filter := PoseFilter.new() +var pointer_transform := Transform3D.IDENTITY +var power_remove_was_down := false +var power_previous_was_down := false +var power_next_was_down := false +var power_hover_key := "" +var shortcuts_was_down := false +var tracking_label: Label3D +var diagnostics_elapsed := 0.0 +var diagnostics_signature := "" +var wheel: Node3D +var wheel_category := 0 +var wheel_was_down := false +var wheel_committed := false +var wheel_dialog_id := "" +var page_left_was_down := false +var page_right_was_down := false +var station_was_down := false +var event_surface: Node3D +var event_was_open := false +var pending_page := "" +var world_surface: Node3D +var initial_table_placed := false +var grabbed_crew: Node3D +var crew_preview: Node3D +var grab_distance := 0.0 +var grab_ship := "" +var grab_crew_id := "" +var grab_shift_down := false +var grab_room := -1 +var grab_label: Label3D +var world_panel_kind := "" +var world_panel_crop_pending := false +var world_panel_open_age := 0.0 +var world_panel_opened_at := 0.0 +var world_panel_open_frame_count := 0 +const HAND_PAGES := ["navigation", "tactical", "shortcuts", "power", "jump", "help"] + + +func _ready() -> void: + demo_mode = "--demo" in OS.get_cmdline_user_args() + _create_stage() + _create_rig() + _create_ships() + enemy_hull_bar = EnemyHullBar.new() + add_child(enemy_hull_bar) + enemy_hull_bar.build() + ray_filter.position_deadband = 0.0008 + ray_filter.angle_deadband = deg_to_rad(0.07) + ray_filter.time_constant = 0.045 + _create_hud() + _create_navigation() + _create_pointer() + _create_event_panel() + _create_world_panel() + _create_grab_preview() + _create_rename_keyboard() + wheel = ShortcutWheel.new() + add_child(wheel) + _set_hazard("clear") + var interface := XRServer.find_interface("OpenXR") + if "--desktop" not in OS.get_cmdline_user_args() and interface != null and (interface.is_initialized() or interface.initialize()): + get_viewport().use_xr = true + xr_active = true + camera.position = Vector3.ZERO + origin.position = Vector3(0.0, 0.0, 1.3) + else: + if "--require-vr" in OS.get_cmdline_user_args(): + push_error("OpenXR headset unavailable. Start SteamVR and connect the headset and controllers, then retry.") + get_tree().quit(2) + return + camera.position = Vector3(0.0, 2.5, 3.0) + camera.look_at(Vector3(0.0, 1.0, 0.0), Vector3.UP) + nav_panel.visible = false + initial_table_placed = true + _poll_bridge_state() + _update_hud() + if "--demo-shot" in OS.get_cmdline_user_args(): + call_deferred("_demo_shot", "laser", "player") + + +func _create_stage() -> void: + var world := WorldEnvironment.new() + var environment := Environment.new() + environment.background_mode = Environment.BG_COLOR + environment.background_color = Color(0.007, 0.012, 0.027) + environment.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR + environment.ambient_light_color = Color(0.43, 0.51, 0.67) + environment.ambient_light_energy = 0.7 + world.environment = environment + add_child(world) + var light := DirectionalLight3D.new() + light.rotation_degrees = Vector3(-45.0, -20.0, 0.0) + light.light_energy = 1.4 + add_child(light) + tabletop_root = Node3D.new() + tabletop_root.name = "FloatingEncounter" + add_child(tabletop_root) + tabletop_root.position.y = 0.7 + tabletop_root.scale = Vector3.ONE * 0.35 + space_environment = SpaceEnvironment.new() + add_child(space_environment) + space_environment.build() + + +func _create_rig() -> void: + origin = XROrigin3D.new() + origin.name = "PlayerOrigin" + add_child(origin) + camera = XRCamera3D.new() + camera.name = "HeadCamera" + camera.current = true + origin.add_child(camera) + left_hand = XRController3D.new() + left_hand.name = "LeftController" + left_hand.tracker = "left_hand" + origin.add_child(left_hand) + right_hand = XRController3D.new() + right_hand.name = "RightController" + right_hand.tracker = "right_hand" + # OpenXR actions end in _pose, but Godot renames their tracker poses. + right_hand.pose = "aim" + origin.add_child(right_hand) + + +func _create_ships() -> void: + player_ship = ShipModel.new() + player_ship.name = "PlayerShip" + tabletop_root.add_child(player_ship) + player_ship.build("kestral", false) + enemy_ship = ShipModel.new() + enemy_ship.name = "EnemyShip" + tabletop_root.add_child(enemy_ship) + enemy_ship.build("rebel_long", true) + combat_effects = CombatEffects.new() + add_child(combat_effects) + _update_ship_positions() + + +func _update_ship_positions() -> void: + # Keep the player's placement stable when a second ship enters the encounter. + player_ship.position = Vector3(0.0, 0.84, 0.0) + player_ship.rotation.y = -PI / 2.0 if int(player_ship.layout_data.get("vertical", 0)) != 0 else 0.0 + enemy_ship.position = Vector3(5.0, 0.84, 0.0) + # Kestrel's bow is local +X; Rebel long's bow is local -Z. + enemy_ship.rotation.y = PI / 2.0 if int(enemy_ship.layout_data.get("vertical", 1)) != 0 else PI + enemy_ship.visible = battle + + +func _create_hud() -> void: + hud_surface = HudSurface.new() + hud_surface.position = Vector3(0.0, -0.16, -1.8) + hud_surface.scale = Vector3.ONE * 0.64 + hud_surface.render_order = 100 + camera.add_child(hud_surface) + hud_surface.canvas.use_original_frame = not demo_mode + pause_label = Label3D.new() + pause_label.font = UiAssets.font("header") + pause_label.outline_size = 0 + pause_label.position = Vector3(0, 0.16, -1.75) + pause_label.font_size = 32 + pause_label.pixel_size = 0.0008 + pause_label.no_depth_test = true + pause_label.render_priority = 101 + pause_label.modulate = Color("c6f3da") + camera.add_child(pause_label) + + +func _create_navigation() -> void: + nav_panel = Node3D.new() + nav_panel.name = "ControllerNavigation" + left_hand.add_child(nav_panel) + nav_panel.top_level = true + nav_panel.position = Vector3(0.0, 0.08, -0.11) + nav_panel.scale = Vector3.ONE * 0.6 + var base := MeshInstance3D.new() + var mesh := BoxMesh.new() + mesh.size = Vector3(0.92, 0.72, 0.016) + base.mesh = mesh + base.material_override = _material(Color(0.035, 0.075, 0.075)) + base.material_override.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA + base.material_override.no_depth_test = true + base.material_override.render_priority = 120 + nav_panel.add_child(base) + base.visible = false + panel_theme = ControllerPanel.new() + nav_panel.add_child(panel_theme) + for i in range(14): + var button := Area3D.new() + button.position = Vector3(-0.22 if i % 2 == 0 else 0.22, 0.235 - floori(i / 2.0) * 0.08, 0.025) + button.set_meta("kind", "navigation") + nav_panel.add_child(button) + nav_buttons.append(button) + var shape := CollisionShape3D.new() + var box := BoxShape3D.new() + box.size = Vector3(0.40, 0.067, 0.04) + shape.shape = box + button.add_child(shape) + var label := Label3D.new() + label.font = UiAssets.font("body") + label.outline_size = 0 + label.font_size = 21 + label.pixel_size = 0.0011 + label.position.z = 0.027 + label.no_depth_test = true + label.render_priority = 123 + label.modulate = Color(0.73, 0.98, 0.81) + button.add_child(label) + var plate := MeshInstance3D.new() + var tile := BoxMesh.new() + tile.size = Vector3(0.405, 0.07, 0.006) + plate.mesh = tile + plate.position.z = 0.015 + plate.material_override = _emissive(Color(0.12, 0.23, 0.21), 0.25) + plate.material_override.no_depth_test = true + plate.material_override.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA + plate.material_override.render_priority = 122 + button.add_child(plate) + label.visible = false + plate.visible = false + nav_title = Label3D.new() + nav_title.font = UiAssets.font("header") + nav_title.outline_size = 0 + nav_title.position = Vector3(0, 0.32, 0.03) + nav_title.font_size = 23 + nav_title.pixel_size = 0.001 + nav_title.no_depth_test = true + nav_title.render_priority = 123 + nav_panel.add_child(nav_title) + nav_title.visible = false + var legend := Label3D.new() + legend.font = UiAssets.font("body") + legend.outline_size = 0 + legend.position = Vector3(0, -0.395, 0.03) + legend.font_size = 18 + legend.pixel_size = 0.001 + legend.no_depth_test = true + legend.render_priority = 123 + legend.text = "Dpad ◀ / ▶: screen ▼: pause ▲: shortcuts\nL trigger: stations R bumper: 1–8 wheel" + nav_panel.add_child(legend) + legend.visible = false + help_label = ControllerHelp.new() + help_label.position.z = 0.045 + nav_panel.add_child(help_label) + map_surface = HudSurface.new() + map_surface.frame_file = "screen_frame.png" + map_surface.render_order = 122 + map_surface.scale = Vector3.ONE * 0.3 + map_surface.position = Vector3(0.0, 0.03, 0.045) + nav_panel.add_child(map_surface) + map_surface.visible = false + tracking_label = Label3D.new() + tracking_label.font = UiAssets.font("body") + tracking_label.outline_size = 0 + tracking_label.position = Vector3(0, -0.86, -1.8) + tracking_label.font_size = 24 + tracking_label.pixel_size = 0.0008 + tracking_label.no_depth_test = true + tracking_label.render_priority = 101 + camera.add_child(tracking_label) + _refresh_navigation() + + +func _create_pointer() -> void: + right_laser = MeshInstance3D.new() + var beam := CylinderMesh.new() + beam.top_radius = 0.0008 + beam.bottom_radius = 0.0008 + beam.height = 1.0 + right_laser.mesh = beam + right_laser.material_override = _emissive(Color(0.32, 1.0, 0.72), 2.0) + right_laser.material_override.no_depth_test = true + right_laser.material_override.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA + right_laser.material_override.render_priority = 127 + right_laser.visible = false + add_child(right_laser) + hover_marker = MeshInstance3D.new() + var ball := SphereMesh.new() + ball.radius = 0.0025 + ball.height = 0.005 + hover_marker.mesh = ball + hover_marker.material_override = _emissive(Color(0.45, 1.0, 0.72), 2.0) + hover_marker.material_override.no_depth_test = true + hover_marker.material_override.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA + hover_marker.material_override.render_priority = 127 + hover_marker.visible = false + add_child(hover_marker) + + +func _create_event_panel() -> void: + event_surface = HudSurface.new() + event_surface.name = "EncounterDialog" + event_surface.frame_file = "event_frame.png" + event_surface.source_rect = Rect2(160, 138, 800, 390) + event_surface.surface_size = Vector2(0.98, 0.588) + event_surface.render_order = 40 + add_child(event_surface) + event_surface.visible = false + + +func _game_actions_available() -> bool: + return (rename_keyboard == null or not rename_keyboard.visible) and hud_state.get("ready", false) and (hud_state.get("ui_mode", "") == "game" or hud_state.get("tactical", false)) and not hud_state.get("blocking_ui", false) and not hud_state.get("map_open", false) and not hud_state.get("panel_open", false) and not hud_state.get("event_open", false) and not hud_state.get("transition", false) + + +func _world_actions_available() -> bool: + return (rename_keyboard == null or not rename_keyboard.visible) and (demo_mode or (hud_state.get("ready", false) and (hud_state.get("ui_mode", "") == "game" or hud_state.get("tactical", false)) and not hud_state.get("blocking_ui", false) and not hud_state.get("map_open", false) and not hud_state.get("panel_open", false) and not hud_state.get("event_open", false) and not hud_state.get("transition", false))) + + +func _create_world_panel() -> void: + world_surface = HudSurface.new() + world_surface.name = "ShipAndStorePanel" + world_surface.frame_file = "panel_frame.png" + world_surface.surface_size = Vector2(1.02, 0.57375) + world_surface.render_order = 45 + add_child(world_surface) + world_surface.visible = false + + +func _create_grab_preview() -> void: + grab_label = Label3D.new() + grab_label.font = UiAssets.font("body") + grab_label.outline_size = 0 + grab_label.font_size = 22 + grab_label.pixel_size = 0.00065 + grab_label.no_depth_test = true + grab_label.render_priority = 126 + grab_label.modulate = Color(0.6, 1.0, 0.72) + add_child(grab_label) + grab_label.visible = false + target_room_label = Label3D.new() + target_room_label.font = UiAssets.font("body") + target_room_label.outline_size = 0 + target_room_label.font_size = 24 + target_room_label.pixel_size = 0.00065 + target_room_label.no_depth_test = true + target_room_label.render_priority = 126 + target_room_label.modulate = Color("ffe4ad") + add_child(target_room_label) + target_room_label.visible = false + + +func _create_rename_keyboard() -> void: + rename_keyboard = RenameKeyboard.new() + rename_keyboard.name = "NativeRenameKeyboard" + add_child(rename_keyboard) + + +func _face_front(node: Node3D) -> void: + var toward := camera.get_camera_transform().origin - node.global_position + if toward.length_squared() < 0.00001: + return + var up := Vector3.UP if absf(toward.normalized().dot(Vector3.UP)) < 0.98 else camera.get_camera_transform().basis.y + # The quads' visible face is +Z. A parent-space Y rotation after look_at + # would reverse the vertical component and tip the wheel toward the floor. + node.global_basis = Basis.looking_at(toward, up, true).scaled(node.global_basis.get_scale()) + + +func _place_initial_table() -> void: + if initial_table_placed: + return + var tracker := XRServer.get_tracker("head") as XRPositionalTracker + if tracker == null or not tracker.has_pose("default") or not tracker.get_pose("default").has_tracking_data: + return + _recenter() + initial_table_placed = true + + +func _position_world_panels() -> void: + # These remain attached to the encounter, not to the head or controller. + var head := camera.get_camera_transform().origin + # Wait for this opening's native capture before freezing its bounds. An old + # cached window must not determine the next window's crop after a slow frame. + if world_surface.visible and world_panel_crop_pending and world_panel_open_age >= 0.25 and _world_panel_has_fresh_frame(): + # Store drop boxes and tooltips are separate native components. Retain a + # fixed full-canvas envelope so hover content cannot escape the first crop + # or resize the pointer mapping while an item is being dragged. + var rect: Rect2i = Rect2i(0, 0, 1280, 720) if world_panel_kind in ["buy", "sell"] else world_surface.canvas.native_used_rect() + if rect.size.x > 0 and rect.size.y > 0: + world_surface.set_crop(Rect2(rect), Vector2(1.02, 1.02 * float(rect.size.y) / float(rect.size.x))) + world_panel_crop_pending = false + var anchor := player_ship.global_position + anchor.y += 0.70 + var horizontal := head - anchor + horizontal.y = 0.0 + if horizontal.length_squared() > 0.001: + anchor += horizontal.normalized() * 0.18 + for panel in [event_surface, world_surface]: + if panel.visible: + panel.global_position = anchor + _face_front(panel) + if map_surface.visible and hud_state.get("map_open", false): + map_surface.global_position = _pilot_map_anchor() + _face_front(map_surface) + if enemy_hull_bar != null: + enemy_hull_bar.global_position = enemy_ship.global_position + Vector3(0, 0.52, 0) + enemy_hull_bar.face_viewer(head) + if rename_keyboard.visible: + var view := camera.get_camera_transform() + rename_keyboard.global_position = view.origin - view.basis.z * 0.72 - view.basis.y * 0.22 + _face_front(rename_keyboard) + + +func _world_panel_has_fresh_frame() -> bool: + if world_surface.canvas.frame_image == null: + return false + if world_surface.canvas.capture_time_unix > 0.0: + return world_surface.canvas.capture_time_unix >= world_panel_opened_at + # Legacy PNG fixtures have no transport timestamp, so require a new image. + return world_surface.canvas.received_frames > world_panel_open_frame_count + + +func _pilot_map_anchor() -> Vector3: + # The native room role follows the actual installed piloting system, even + # on layouts whose cockpit is away from the nose. Distances stay in metres + # when the player resizes or turns the encounter. + var cockpit := player_ship.global_position + for id in player_ship.room_nodes: + if str(player_ship.room_nodes[id].get_meta("system_role", "")) == "pilot": + cockpit = player_ship.to_global(player_ship.room_points[id]) + break + var local_bow := Vector3.FORWARD if int(player_ship.layout_data.get("vertical", 0)) != 0 else Vector3.RIGHT + var bow: Vector3 = (player_ship.global_basis * local_bow).normalized() + var up: Vector3 = player_ship.global_basis.y.normalized() + return cockpit + up * 0.62 + bow * 0.24 + + +func _cycle_hand_page(direction: int) -> void: + var current_page := pending_page if pending_page != "" else nav_page + var index := HAND_PAGES.find(current_page) + _open_hand_page(HAND_PAGES[posmod(maxi(0, index) + direction, HAND_PAGES.size())]) + + +func _open_hand_page(page: String) -> void: + nav_page = page + if pending_page != "": + pending_page = page + _refresh_navigation() + return + # Close the native surface before opening another one. Wait for its state + # acknowledgment rather than issuing two Escape/toggle commands together. + if hud_state.get("map_open", false) and page != "jump": + pending_page = page + _command("navigate", {"screen": "menu"}) + elif hud_state.get("tactical", false) and page != "tactical": + pending_page = page + _command("navigate", {"screen": "tactical"}) + elif page == "jump" and not hud_state.get("map_open", false) and _game_actions_available() and hud_state.get("navigation", {}).get("jump", false): + _command("navigate", {"screen": "jump"}) + elif page == "tactical" and not hud_state.get("tactical", false) and _game_actions_available(): + _command("navigate", {"screen": "tactical"}) + _refresh_navigation() + + +func _toggle_tactical() -> void: + if rename_keyboard.visible or not hud_state.get("ready", demo_mode) or hud_state.get("ui_mode", "") == "menu": + return + _open_hand_page("navigation" if hud_state.get("tactical", false) else "tactical") + + +func _change_system_power(direction: int) -> void: + if nav_page != "power" or not _game_actions_available(): + return + var row: Dictionary = panel_theme.selected_system() + if row.is_empty() or int(row.get("id", -1)) < 0: + return + _command("system_power", {"system_id": int(row.id), "system_key": str(row.key), + "direction": direction, "modifiers": {"shift": false, "control": false}}) + + +func _update_power_controls(remove: bool, previous: bool, next: bool) -> void: + if nav_page == "power" and _game_actions_available(): + if previous and not power_previous_was_down: + panel_theme.step_selection(-1) + if next and not power_next_was_down: + panel_theme.step_selection(1) + if remove and not power_remove_was_down: + _change_system_power(-1) + power_remove_was_down = remove + power_previous_was_down = previous + power_next_was_down = next + + +func _update_wheel() -> void: + var down := right_hand.get_is_active() and right_hand.is_button_pressed("wheel_button") + if down and not wheel_was_down: + wheel_committed = false + wheel_dialog_id = str(hud_state.get("dialog", {}).get("id", "")) + wheel.visible = not rename_keyboard.visible and (hud_state.get("event_open", false) or _game_actions_available()) + _refresh_wheel() + if wheel.visible: + wheel.global_position = _pointer_ray().from + Vector3(0, 0.17, -0.1) + _face_front(wheel) + wheel.choose(right_hand.get_vector2("primary")) + if wheel_dialog_id != str(hud_state.get("dialog", {}).get("id", "")): + wheel_committed = true + wheel.visible = false + if not down and wheel_was_down: + if wheel.visible: + _commit_wheel() + wheel.visible = false + wheel_was_down = down + + +func _refresh_wheel() -> void: + var entries: Array = [] + if hud_state.get("event_open", false): + for choice in hud_state.get("dialog", {}).get("choices", []): + entries.append({"label": "Choice %d" % (int(choice.get("index", 0)) + 1), "choice": choice.get("index", 0), "enabled": choice.get("enabled", true)}) + wheel.set_entries(entries, "EVENT\nCHOICES") + return + var player: Dictionary = hud_state.get("player", {}) + if wheel_category == 0: + for i in range(8): + entries.append({"label": "W%d" % (i + 1) if i < 4 else "D%d" % (i - 3), "key": 49 + i, + "enabled": i < player.get("weapons", []).size() if i < 4 else i - 4 < int(hud_state.get("drone_slots", 0))}) + wheel.set_entries(entries, "WEAPONS\nDRONES") + elif wheel_category == 1: + var systems: Dictionary = player.get("systems", {}) + for item in [["SHIELD", "shields", 97], ["ENGINE", "engines", 115], ["OXYGEN", "oxygen", 102], ["MED/CLONE", "medbay", 100], ["WEAPONS", "weapons", 119], ["DRONES", "drones", 101], ["TELEPORT", "teleporter", 103], ["CLOAK", "cloaking", 104]]: + entries.append({"label": item[0], "key": item[2], "enabled": systems.get(item[1], false) or (item[1] == "medbay" and systems.get("clonebay", false))}) + wheel.set_entries(entries, "SYSTEM\nPOWER") + else: + var systems: Dictionary = player.get("systems", {}) + for item in [["CLOAK", "cloaking", 99], ["SEND", "teleporter", 116], ["RETURN", "teleporter", 114], ["HACK", "hacking", 110], ["MIND", "mind", 109], ["BATTERY", "battery", 98], ["AUTOFIRE", "weapons", 118], ["ALL CREW", "crew", 113]]: + entries.append({"label": item[0], "key": item[2], "enabled": item[1] == "crew" or systems.get(item[1], false)}) + wheel.set_entries(entries, "SYSTEM\nACTIONS") + + +func _commit_wheel() -> void: + if rename_keyboard.visible: + wheel_committed = true + wheel.visible = false + return + if wheel_committed or wheel.selected < 0 or wheel.selected >= wheel.entries.size(): + return + var entry: Dictionary = wheel.entries[wheel.selected] + if not entry.get("enabled", true): + return + wheel_committed = true + if entry.has("choice"): + _command("event_choice", {"index": entry.choice, "dialog_id": wheel_dialog_id}) + else: + _command("key", {"key": entry.key}) + wheel.visible = false + + +func _process(delta: float) -> void: + if world_surface.visible: + world_panel_open_age += delta + if not paused: + elapsed += delta + state_poll_elapsed += delta + if state_poll_elapsed >= 0.05: + state_poll_elapsed = 0.0 + _poll_bridge_state() + combat_effects.simulation_paused = paused + player_ship.simulation_paused = paused + enemy_ship.simulation_paused = paused + space_environment.simulation_paused = paused + var viewer := camera.get_camera_transform().origin + player_ship.set_viewer_position(viewer) + enemy_ship.set_viewer_position(viewer) + if xr_active: + _place_initial_table() + _resolve_controller_pose(right_hand) + _resolve_controller_pose(left_hand) + _update_stable_ray(delta) + shift_down = _pressed(right_hand, "grip") + control_down = right_hand.is_button_pressed("primary_click") + _update_input_diagnostics(delta) + _face_navigation_to_headset(delta) + _position_world_panels() + _update_pointer() + var ray := _pointer_ray() + _update_crew_grab(ray.from, ray.direction) + _update_wheel() + if wheel.visible: + right_laser.visible = false + hover_marker.visible = false + var trigger_down := right_hand.get_is_active() and _pressed(right_hand, "trigger") + if trigger_down and not trigger_was_down and wheel.visible: + _commit_wheel() + elif trigger_down and not trigger_was_down: + _select_from_ray(ray.from, ray.direction) + 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 + var cancel_down := right_hand.is_button_pressed("by_button") + if cancel_down and not cancel_was_down: + if wheel.visible: + wheel_committed = true + wheel.visible = false + else: + _secondary_click(ray.from, ray.direction) + cancel_was_down = cancel_down + var inspect_down := right_hand.is_button_pressed("ax_button") + if inspect_down and not inspect_was_down: + if wheel.visible and not hud_state.get("event_open", false): + wheel_category = (wheel_category + 1) % 3 + _refresh_wheel() + else: + enemy_ship.inspect_rooms = not enemy_ship.inspect_rooms + enemy_ship.apply_live(enemy_ship.live_data) + inspect_was_down = inspect_down + var frame := _is_frame_hand(left_hand) + var tactical_down := left_hand.is_button_pressed("tactical_button") if frame else left_hand.is_button_pressed("ax_button") and nav_page != "power" + if tactical_down and not tactical_was_down: + _toggle_tactical() + tactical_was_down = tactical_down + var shortcuts_down := left_hand.is_button_pressed("by_button") and (frame or nav_page != "power") + if shortcuts_down and not shortcuts_was_down: + _open_hand_page("shortcuts" if nav_page != "shortcuts" else "navigation") + shortcuts_was_down = shortcuts_down + var page_left := left_hand.is_button_pressed("page_left") + var page_right := left_hand.is_button_pressed("page_right") + if page_left and not page_left_was_down: + _cycle_hand_page(-1) + if page_right and not page_right_was_down: + _cycle_hand_page(1) + page_left_was_down = page_left + page_right_was_down = page_right + var stations := _pressed(left_hand, "trigger") + if stations and not station_was_down and _game_actions_available(): + if nav_page == "power": + _change_system_power(1) + else: + _command("key", {"key": 47 if shift_down else 13}) + station_was_down = stations + _update_power_controls(left_hand.is_button_pressed("power_remove"), right_hand.is_button_pressed("power_previous") if frame else left_hand.is_button_pressed("ax_button"), right_hand.is_button_pressed("power_next") if frame else left_hand.is_button_pressed("by_button")) + var grip_down := left_hand.get_is_active() and _pressed(left_hand, "grip") + if grip_down and gripping: + tabletop_root.global_position += left_hand.global_position - grip_last_position + gripping = grip_down + grip_last_position = left_hand.global_position + var stick := left_hand.get_vector2("primary") + if stick.length() > 0.2: + tabletop_root.rotate_y(-stick.x * delta) + tabletop_root.scale = Vector3.ONE * clampf(tabletop_root.scale.x + stick.y * delta * 0.35, 0.2, 1.5) + var recenter_down := left_hand.is_button_pressed("primary_click") + if recenter_down and not recenter_was_down: + _recenter() + recenter_was_down = recenter_down + hover_elapsed += delta + if hover_elapsed >= 0.12 and not wheel.visible: + hover_elapsed = 0.0 + _hover_ui(ray.from, ray.direction) + + +func _face_navigation_to_headset(delta: float = 1.0 / 90.0) -> void: + if not left_hand.get_is_active(): + nav_panel.visible = false + panel_filter.reset() + return + nav_panel.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: + var up := Vector3.UP if absf(toward.normalized().dot(Vector3.UP)) < 0.98 else camera.get_camera_transform().basis.y + var target := Transform3D(Basis.looking_at(toward, up, true).scaled(Vector3.ONE * 0.6), point) + nav_panel.global_transform = panel_filter.update(target, delta) + + +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) + + +func _update_stable_ray(delta: float) -> void: + if not right_hand.get_is_active(): + ray_filter.reset() + return + pointer_transform = ray_filter.update(right_hand.global_transform, delta) + + +func _pointer_ray() -> Dictionary: + var pose := pointer_transform if ray_filter.initialized else right_hand.global_transform + return {"from": pose.origin, "direction": -pose.basis.z.normalized()} + + +func _update_pointer() -> void: + if not right_hand.get_is_active(): + right_laser.visible = false + hover_marker.visible = false + return + var ray := _pointer_ray() + var start: Vector3 = ray.from + var direction: Vector3 = ray.direction + var hit := {} + var keyboard_hit: Dictionary = rename_keyboard.ray_hit(start, direction) + var hand_hit := {} if rename_keyboard.visible else _hand_panel_hit(start, direction) + var surface_hit := {} if rename_keyboard.visible else _surface_hit(start, direction) + # A left-hand power press in this frame must use the card under the visible + # right-hand ray, rather than waiting for the slower native hover interval. + var hover_key := str(hand_hit.get("system_key", "")) if nav_page == "power" and not wheel.visible else "" + if hover_key != power_hover_key: + power_hover_key = hover_key + if hover_key != "": + panel_theme.select_system(hover_key) + if not keyboard_hit.is_empty(): + hit = keyboard_hit + elif not hand_hit.is_empty(): + hit = hand_hit + elif surface_hit.is_empty() and _world_actions_available(): + if grabbed_crew != null: + hit = _room_hit(start, direction, grab_ship, 0.012) + elif not _active_target().is_empty(): + hit = _target_room_hit(start, direction) + if hit.is_empty(): + var query := PhysicsRayQueryParameters3D.create(start, start + direction * 8.0) + query.collide_with_areas = true + query.collide_with_bodies = false + hit = get_world_3d().direct_space_state.intersect_ray(query) + var end := start + direction * 4.0 + if not hit.is_empty(): + end = hit.get("room_position", hit["position"]) + hover_marker.global_position = end + hover_marker.visible = true + else: + hover_marker.visible = false + if not surface_hit.is_empty(): + var pixel: Vector2i = surface_hit["pixel"] + var surface: Node3D = surface_hit["surface"] + end = surface.pixel_world(pixel) + hover_marker.global_position = end + hover_marker.visible = true + _update_target_feedback(hit if surface_hit.is_empty() and hand_hit.is_empty() and keyboard_hit.is_empty() else {}) + # A thin cylinder is visible in stereo; a one-pixel line can vanish at distance. + var length := start.distance_to(end) + right_laser.global_position = (start + end) * 0.5 + if length > 0.001: + var toward := (end - start).normalized() + var up := Vector3.UP if absf(toward.dot(Vector3.UP)) < 0.99 else Vector3.RIGHT + var orientation := Basis.looking_at(toward, up) * Basis(Vector3.RIGHT, PI / 2.0) + right_laser.global_basis = Basis(orientation.x, orientation.y * length, orientation.z) + right_laser.visible = true + + +func _unhandled_input(event: InputEvent) -> void: + if not xr_active and event is InputEventWithModifiers: + shift_down = event.shift_pressed + control_down = event.ctrl_pressed + if event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_LEFT and event.pressed and not xr_active: + _select_from_ray(camera.project_ray_origin(event.position), camera.project_ray_normal(event.position)) + if event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_LEFT and not event.pressed and not xr_active: + _release_pointer(camera.project_ray_origin(event.position), camera.project_ray_normal(event.position)) + if event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_RIGHT and event.pressed and not xr_active: + _secondary_click(camera.project_ray_origin(event.position), camera.project_ray_normal(event.position)) + if event is InputEventMouseMotion and not xr_active: + _hover_ui(camera.project_ray_origin(event.position), camera.project_ray_normal(event.position)) + if event is InputEventKey and event.pressed and (not event.echo or rename_keyboard.visible): + if rename_keyboard.visible: + var data := {"entry_id": rename_keyboard.entry.get("id", "")} + match event.keycode: + KEY_ENTER, KEY_KP_ENTER: data.op = "confirm" + KEY_ESCAPE: data.op = "cancel" + KEY_BACKSPACE: data.op = "backspace" + _: + if event.unicode < 32: + return + data.op = "insert" + data.text = String.chr(event.unicode) + _command("text_input", data) + return + if not demo_mode: + if event.keycode == KEY_SPACE: + _toggle_pause() + elif event.keycode == KEY_ESCAPE: + _command("key", {"key": 27}) + elif event.keycode >= KEY_1 and event.keycode <= KEY_8: + _command("key", {"key": 49 + event.keycode - KEY_1}) + elif event.keycode == KEY_E: + enemy_ship.inspect_rooms = not enemy_ship.inspect_rooms + enemy_ship.apply_live(enemy_ship.live_data) + elif event.keycode == KEY_R: + _recenter() + elif event.keycode == KEY_J: + _command("navigate", {"screen": "jump"}) + elif event.keycode == KEY_F8: + _command("key", {"key": 289}) + return + match event.keycode: + KEY_SPACE: + _toggle_pause() + KEY_F: + battle = not battle + _update_ship_positions() + _update_hud() + KEY_0: + _set_hazard("clear") + KEY_1: + _set_hazard("asteroid") + KEY_2: + _set_hazard("sun") + KEY_3: + _set_hazard("storm") + KEY_4: + _set_hazard("nebula") + KEY_5: + _set_hazard("pulsar") + KEY_ESCAPE: + crew_selected = false + _update_hud() + KEY_T: + _demo_shot("laser", "player") + KEY_Y: + _demo_shot("laser", "enemy") + KEY_M: + _demo_shot("missile", "player") + KEY_I: + _demo_shot("ion", "player") + KEY_B: + _demo_shot("beam", "player") + KEY_O: + _demo_shot("bomb", "player") + + +func _select_from_ray(from: Vector3, direction: Vector3) -> void: + keyboard_press = false + var keyboard_hit: Dictionary = rename_keyboard.ray_hit(from, direction) + if not keyboard_hit.is_empty(): + keyboard_press = true + var data: Dictionary = rename_keyboard.activate(keyboard_hit) + if not data.is_empty(): + _command("text_input", data) + return + if rename_keyboard.visible: + return + var hand_hit := _hand_panel_hit(from, direction) + if not hand_hit.is_empty() and not hand_hit.has("surface"): + if hand_hit.has("system_key"): + panel_theme.select_system(str(hand_hit.system_key)) + elif hand_hit.has("button"): + _navigation_action(str(hand_hit.button.get_meta("action"))) + return + var surface_hit := _surface_hit(from, direction) + if not surface_hit.is_empty(): + held_surface = surface_hit["surface"] + held_pixel = surface_hit["pixel"] + _command("ui_mouse", {"x": held_pixel.x, "y": held_pixel.y, "phase": "down"}) + return + if not bridge_connected and not demo_mode: + return + if _world_actions_available() and not _active_target().is_empty(): + 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"}) + return + var query := PhysicsRayQueryParameters3D.create(from, from + direction.normalized() * 12.0) + query.collide_with_areas = true + query.collide_with_bodies = false + var hit := get_world_3d().direct_space_state.intersect_ray(query) + if hit.is_empty(): + return + var target: Object = hit["collider"] + if not target.has_meta("kind"): + return + if str(target.get_meta("kind")) != "navigation" and not _world_actions_available(): + return + match str(target.get_meta("kind")): + "crew": + if not bool(target.get_meta("controllable", true)): + return + crew_selected = true + crew_id_selected = str(target.get_meta("crew_id")) + crew_ship_selected = str(target.get_meta("ship")) + _command("select_crew", {"crew_id": crew_id_selected}) + _begin_crew_grab(target as Node3D, from) + last_command = "Hold trigger to grab; release over a room to order crew" + "door": + if str(target.get_meta("ship", "")) == "player": + _command("door_toggle", {"door_id": int(target.get_meta("door_id"))}) + "room": + var ship := str(target.get_meta("ship")) + var room_id := int(target.get_meta("room_id")) + if ship == crew_ship_selected and crew_selected: + _command("move_crew", {"crew_id": crew_id_selected, "room_id": room_id}) + elif not _active_target().is_empty() and ship in _active_target().get("ships", []): + beam_drag = str(_active_target().kind) == "weapon" and _selected_weapon_is_beam() + _command("target_room", {"room_id": room_id, "ship": ship, "phase": "down" if beam_drag else "click"}) + else: + last_command = "%s room %d" % [ship.capitalize(), room_id] + "navigation": + _navigation_action(str(target.get_meta("action"))) + _update_hud() + + +func _command(action: String, data: Dictionary) -> void: + data = data.duplicate(true) + data["modifiers"] = {"shift": false, "control": false} if action == "text_input" else data.get("modifiers", {"shift": shift_down, "control": control_down}) + last_command = "%s %s" % [action, JSON.stringify(data)] + if demo_mode: + return + var command := {"action": action, "data": data, "time_unix": Time.get_unix_time_from_system()} + var output_path := _local_data_path("commands.jsonl") + var output := FileAccess.open(output_path, FileAccess.READ_WRITE if FileAccess.file_exists(output_path) else FileAccess.WRITE) + if output != null: + output.seek_end() + output.store_line(JSON.stringify(command)) + if action != "ui_move": + print("FTL_TABLETOP_COMMAND ", JSON.stringify(command)) + + +func _toggle_pause() -> void: + if rename_keyboard.visible: + return + if demo_mode: + paused = not paused + else: + _command("key", {"key": 32}) + _update_hud() + + +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)) + 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) + # A single live-state cue is shared by both eyes and every native view. + # 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))) + + +func _demo_shot(kind: String, source: String) -> void: + if not battle: + return + var receiver: Node3D = enemy_ship if source == "player" else player_ship + var outcome := "shield" if receiver.shield_charge > 0 and kind in ["laser", "ion"] else "hull" + _present_shot({"kind": kind, "source": source, "weapon_slot": 0, "target_room": 0, + "end_room": 4, "outcome": outcome, "duration": 0.9}) + + +func _present_shot(event: Dictionary) -> void: + var source := str(event.get("source", "")) + if source == "environment": + var receiver: Node3D = enemy_ship if int(event.get("ship", 0)) == 1 else player_ship + combat_effects.spawn_shot(event, receiver, receiver) + return + if source not in ["player", "enemy"] or (not battle and not event.has("drone_id")): + return + if str(event.get("kind", "laser")) not in ["laser", "ion", "missile", "beam", "bomb"]: + return + combat_effects.spawn_shot(event, player_ship if source == "player" else enemy_ship, + enemy_ship if source == "player" else player_ship) + + +func _poll_bridge_state() -> void: + if demo_mode: + return + var path := _local_data_path("live_state.json") + if not FileAccess.file_exists(path): + bridge_connected = false + return + var signature := FileAccess.get_sha256(path) + bridge_connected = Time.get_unix_time_from_system() - float(hud_state.get("bridge_time_unix", 0)) < 2.0 + if signature == state_signature: + return + var decoder := JSON.new() + if decoder.parse(FileAccess.get_file_as_string(path)) != OK or not decoder.data is Dictionary: + return + var parsed: Variant = decoder.data + state_signature = signature + bridge_connected = true + var state: Dictionary = parsed + bridge_connected = Time.get_unix_time_from_system() - float(state.get("bridge_time_unix", 0)) < 2.0 + if not bridge_connected: + return + hud_state = state.duplicate(true) + _apply_text_entry(state.get("text_entry", {})) + space_environment.apply_state(state) + var event_open := bool(state.get("event_open", false)) + event_surface.visible = event_open + var world_was_open: bool = world_surface.visible + world_surface.visible = bool(state.get("panel_open", false)) + var panel_kind := str(state.get("panel_kind", "window")) + if world_surface.visible and (not world_was_open or panel_kind != world_panel_kind): + world_panel_kind = panel_kind + world_panel_crop_pending = true + world_panel_open_age = 0.0 + world_panel_opened_at = Time.get_unix_time_from_system() + world_panel_open_frame_count = world_surface.canvas.received_frames + if grabbed_crew != null and not _world_actions_available(): + _end_crew_grab() + event_was_open = event_open + if event_open: + event_surface.set_crop(Rect2(313 if state.get("dialog", {}).get("centered", true) else 163, 138, 650, 390), Vector2(0.98, 0.588)) + _face_front(event_surface) + if pending_page != "" and not state.get("map_open", false) and not state.get("tactical", false): + var next_page := pending_page + pending_page = "" + _open_hand_page(next_page) + var session := str(state.get("bridge_session", "")) + var first_state := bridge_session == "" or session != bridge_session + if session != bridge_session: + seen_shots.clear() + bridge_session = session + weapon_selected = int(state.get("weapon_selected", -1)) >= 0 + crew_selected = false + for side in ["player", "enemy"]: + for crew in state.get(side, {}).get("crew", []): + if crew.get("selected", false) and crew.get("controllable", false): + crew_selected = true + crew_id_selected = str(crew["id"]) + crew_ship_selected = side + break + player_ship.visible = bool(state.get("ready", false)) + for side in ["player", "enemy"]: + if state.has(side): + var ship: Node3D = player_ship if side == "player" else enemy_ship + var data: Dictionary = state[side] + if str(data.get("asset_key", "")) != ship.ship_name: + ship.build(str(data["asset_key"]), side == "enemy") + if side == "enemy": + pointed_room = -1 + _update_ship_positions() + ship.apply_live(data) + if enemy_hull_bar != null: + enemy_hull_bar.set_state(state.get("enemy", {}) if state.get("combat", false) else {}) + var map_on_controller := (bool(state.get("map_open", false)) or bool(state.get("tactical", false))) and xr_active + map_surface.visible = map_on_controller + hud_surface.visible = true + hud_surface.canvas.frame_file = "screen_frame.png" if not xr_active and (state.get("map_open", false) or state.get("tactical", false)) else "hud_frame.png" + map_surface.set_state(state) + _refresh_navigation() + var new_battle := bool(state.get("combat", battle)) + if new_battle != battle: + battle = new_battle + _update_ship_positions() + var new_hazard := str(state.get("hazard", hazard)) + if new_hazard in ["clear", "asteroid", "sun", "storm", "nebula", "pulsar"] and new_hazard != hazard: + _set_hazard(new_hazard) + paused = bool(state.get("paused", paused)) or bool(state.get("frozen", false)) + hull_current = int(state.get("hull", hull_current)) + hull_max = int(state.get("hull_max", hull_max)) + shield_level = int(state.get("shield", shield_level)) + reactor_power = int(state.get("reactor", reactor_power)) + last_command = "Encounter state updated" + _update_hud() + if world_surface != null and rename_keyboard != null: + _position_world_panels() + for event in state.get("shots", []): + if not event is Dictionary or not event.has("id"): + continue + var id := str(event["id"]) + if not seen_shots.has(id): + seen_shots[id] = true + if first_state: + var active := false + for projectile in state.get("projectiles", []): + if str(projectile["id"]) == str(event.get("projectile_id", "")): + active = true + if not active or event.get("phase", "fire") == "impact": + continue + if event.get("phase", "fire") == "impact": + combat_effects.resolve_live(event, enemy_ship if int(event.get("ship", 0)) == 1 else player_ship) + else: + _present_shot(event) + combat_effects.update_live_projectiles(state.get("projectiles", [])) + + +func _apply_text_entry(entry: Dictionary) -> void: + rename_keyboard.set_entry(entry) + if rename_keyboard.visible: + # Native text entry can begin while a previously opened wheel is held. + # Its eventual release must not send a gameplay shortcut into the name. + wheel.visible = false + wheel_committed = true + beam_drag = false + if grabbed_crew != null: + _end_crew_grab() + + +func _surface_hit(from: Vector3, direction: Vector3) -> Dictionary: + if rename_keyboard.visible: + return {} + var hand_hit := _hand_panel_hit(from, direction) + if not hand_hit.is_empty(): + return hand_hit if hand_hit.has("surface") else {} + var closest := {} + var closest_distance := INF + for surface in [hud_surface, world_surface, event_surface, map_surface]: + var pixel: Variant = surface.ray_pixel(from, direction) + if pixel != null: + # Hand UI has already won. HUD pixels win over world panels. + if surface == hud_surface: + return {"surface": surface, "pixel": pixel} + var point: Vector3 = surface.pixel_world(pixel) + var distance := from.distance_to(point) + if distance < closest_distance: + closest_distance = distance + closest = {"surface": surface, "pixel": pixel} + var query := PhysicsRayQueryParameters3D.create(from, from + direction * 12.0) + query.collide_with_areas = true + query.collide_with_bodies = false + var hit := get_world_3d().direct_space_state.intersect_ray(query) + if not hit.is_empty() and hit["collider"].get_meta("kind", "") == "navigation" and from.distance_to(hit["position"]) < closest_distance: + return {} + return closest + + +func _hand_panel_hit(from: Vector3, direction: Vector3) -> Dictionary: + if not nav_panel.is_visible_in_tree(): + return {} + # The native Tactical quad is in front of the themed panel. Pick each + # actual display plane; a shared plane shifts oblique controller rays. + if map_surface.get_parent() == nav_panel: + var native_pixel: Variant = map_surface.ray_pixel(from, direction) + if native_pixel != null: + return {"position": map_surface.pixel_world(native_pixel), "surface": map_surface, "pixel": native_pixel} + var start := nav_panel.to_local(from) + var ray := nav_panel.to_local(from + direction) - start + if absf(ray.z) < 0.00001: + return {} + var distance := (0.012 - start.z) / ray.z + if distance <= 0.0: + return {} + var point := start + distance * ray + if absf(point.x) > 0.46 or absf(point.y) > 0.36: + return {} + var result := {"position": nav_panel.to_global(point)} + if nav_page == "power": + var power_hit: Dictionary = panel_theme.ray_hit(from, direction) + if not power_hit.is_empty(): + return power_hit + return result + for button in nav_buttons: + if button.visible and Rect2(Vector2(button.position.x - 0.20, button.position.y - 0.0335), Vector2(0.40, 0.067)).has_point(Vector2(point.x, point.y)): + result.button = button + return result + return result + + +func _update_target_feedback(hit: Dictionary) -> void: + var target := _active_target() + var id := int(hit.get("room_id", -1)) if not target.is_empty() and grabbed_crew == null else -1 + var side := str(hit.get("ship", "")) if id >= 0 else "" + if id != pointed_room or side != pointed_ship: + player_ship.set_drop_target(id if side == "player" else -1) + enemy_ship.set_drop_target(id if side == "enemy" else -1) + pointed_room = id + pointed_ship = side + target_room_label.visible = id >= 0 + if id >= 0: + var ship: Node3D = player_ship if side == "player" else enemy_ship + var role := str(ship.room_nodes[id].get_meta("system_role", "")) + var kind := str(target.get("kind", "weapon")) + target_room_label.text = ("TARGET" if kind == "weapon" else kind.to_upper()) + " • " + (role.to_upper() if role != "" else "ROOM") + target_room_label.global_position = hit.position + Vector3(0, 0.045, 0) + _face_front(target_room_label) + + +func _pressed(hand: XRController3D, action: String) -> bool: + return hand.is_button_pressed(action + "_click") or hand.get_float(action) > 0.65 + + +func _resolve_controller_pose(hand: XRController3D) -> void: + var tracker := XRServer.get_tracker(hand.tracker) as XRPositionalTracker + if tracker == null: + return + for name in ["aim", "default", "grip"]: + if tracker.has_pose(name) and tracker.get_pose(name).has_tracking_data: + hand.pose = name + return + + +func _update_input_diagnostics(delta: float) -> void: + diagnostics_elapsed += delta + var status := "" + if not right_hand.get_is_active(): + status = "RIGHT CONTROLLER NOT TRACKED — check SteamVR controller status" + elif shift_down or control_down: + status = ("SHIFT " if shift_down else "") + ("CTRL" if control_down else "") + if status != tracking_label.text: + tracking_label.text = status + if diagnostics_elapsed < 1.0: + return + diagnostics_elapsed = 0.0 + var hands := {} + for hand in [left_hand, right_hand]: + var tracker := XRServer.get_tracker(hand.tracker) as XRPositionalTracker + hands[str(hand.tracker)] = {"tracked": hand.get_is_active(), "pose": str(hand.pose), + "profile": str(tracker.profile) if tracker != null else "missing", + "trigger": hand.get_float("trigger"), "trigger_click": hand.is_button_pressed("trigger_click"), + "grip": hand.get_float("grip"), "grip_click": hand.is_button_pressed("grip_click")} + var info := {"hands": hands, "ray_visible": right_laser.visible, "shift": shift_down, + "control": control_down, "time_unix": Time.get_unix_time_from_system()} + var file := FileAccess.open(_local_data_path("xr_input.json"), FileAccess.WRITE) + if file != null: + file.store_string(JSON.stringify(info)) + var signature := JSON.stringify(hands) + if signature != diagnostics_signature: + print("FTLVR_XR_INPUT ", signature) + diagnostics_signature = signature + + +func _navigation_action(action: String) -> void: + if action.begins_with("page:"): + _open_hand_page(action.trim_prefix("page:")) + elif action.begins_with("key:"): + _command("key", {"key": int(action.trim_prefix("key:"))}) + elif action == "recenter": + _recenter() + else: + _command("navigate", {"screen": action}) + + +func _navigation_entries() -> Array: + var entries: Array = [] + var game := _game_actions_available() + if nav_page == "power": + return [] + if map_surface.visible: + return [["CLOSE MAP" if hud_state.get("map_open", false) else "CLOSE TACTICAL", + "menu" if hud_state.get("map_open", false) else "tactical"], ["HELP", "page:help"]] + if nav_page == "help": + return [["BACK", "page:navigation"]] + if nav_page == "navigation": + if game: + for name in ["jump", "ship", "store"]: + if hud_state.get("navigation", {}).get(name, false): + entries.append([name.to_upper(), name]) + entries.append(["SHORTCUTS", "page:shortcuts"]) + entries.append(["TACTICAL VIEW", "tactical"]) + entries.append(["MENU / BACK", "menu"]) + entries.append(["CONTROLS / HELP", "page:help"]) + entries.append(["RECENTER", "recenter"]) + return entries + if not game: + return [["BACK", "page:navigation"]] + if nav_page == "jump": + return [["OPEN JUMP MAP", "jump"], ["BACK", "page:navigation"]] if hud_state.get("navigation", {}).get("jump", false) else [["JUMP UNAVAILABLE", "page:navigation"]] + if nav_page == "shortcuts": + return [["CREW / DOORS", "page:crew"], ["WEAPONS / DRONES", "page:weapons"], + ["SYSTEM POWER", "page:power"], ["SYSTEM ACTIONS", "page:systems"], + ["BACK", "page:navigation"], ["CONTROLS / HELP", "page:help"]] + if nav_page == "crew": + entries = [["ALL CREW (Q)", "key:113"], ["SAVE STATIONS (/)", "key:47"], + ["RETURN (ENTER)", "key:13"], ["OPEN DOORS (Z)", "key:122"], ["CLOSE DOORS (X)", "key:120"]] + elif nav_page == "weapons": + for i in range(hud_state.get("player", {}).get("weapons", []).size()): + entries.append(["WEAPON %d (%d)" % [i + 1, i + 1], "key:%d" % (49 + i)]) + for i in range(int(hud_state.get("drone_slots", 0))): + entries.append(["DRONE %d (%d)" % [i + 1, (i + 5) % 10], "key:%d" % (48 + ((i + 5) % 10))]) + entries.append(["AUTOFIRE (V)", "key:118"]) + else: + var systems: Dictionary = hud_state.get("player", {}).get("systems", {}) + var keys := {"cloaking":[["CLOAK (C)",99]],"teleporter":[["SEND (T)",116],["RETURN (R)",114]], + "hacking":[["HACK (N)",110]],"mind":[["MIND CONTROL (M)",109]],"battery":[["BATTERY (B)",98]]} + for name in keys: + if systems.get(name, false): + for entry in keys[name]: + entries.append([entry[0], "key:%d" % entry[1]]) + entries.append(["BACK", "page:shortcuts"]) + entries.append(["CONTROLS / HELP", "page:help"]) + return entries + + +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)) + if hud_state.get("map_open", false): + if map_surface.get_parent() != self: + map_surface.reparent(self, false) + map_surface.scale = Vector3.ONE + map_surface.set_render_order(45) + map_surface.set_crop(Rect2(335, 80, 760, 590), Vector2(1.02, 1.02 * 590.0 / 760.0)) + else: + if map_surface.get_parent() != nav_panel: + map_surface.reparent(nav_panel, false) + map_surface.position = Vector3(0, 0.03, 0.045) + map_surface.rotation = Vector3.ZERO + map_surface.scale = Vector3.ONE * 0.3 + map_surface.set_render_order(122) + map_surface.set_crop(Rect2(0, 0, 1280, 720), HudSurface.SURFACE_SIZE) + help_label.visible = nav_page == "help" + nav_title.text = ("JUMP MAP" if hud_state.get("map_open", false) else "TACTICAL VIEW") if map_surface.visible else nav_page.to_upper() + var heading: String = nav_title.text + if bool(hud_state.get("paused", false)): + nav_title.text += " • PAUSED" + var entries := _navigation_entries() + var themed_buttons: Array = [] + 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) + if button.visible: + button.set_meta("action", entries[i][1]) + button.get_child(1).text = entries[i][0] + button.position.y = -0.29 if (map_surface.visible and map_surface.get_parent() == nav_panel) or help_label.visible else 0.235 - floori(i / 2.0) * 0.08 + themed_buttons.append({"label": entries[i][0], "position": Vector2(button.position.x, button.position.y), "enabled": true}) + if nav_page == "power": + panel_theme.set_power(hud_state.get("player", {}), bool(hud_state.get("paused", false))) + else: + panel_theme.set_navigation(heading, themed_buttons, bool(hud_state.get("paused", false))) + if world_surface != null and rename_keyboard != null: + _position_world_panels() + + +func _hover_ui(from: Vector3, direction: Vector3) -> void: + if keyboard_press or rename_keyboard.visible: + return + if nav_page == "power": + # Local card hover is handled before power buttons every frame. A + # stationary ray must not undo a system selected with X/Y. + return + var now := Time.get_ticks_msec() / 1000.0 + if now - last_hover_time < 0.1: + return + last_hover_time = now + var hit := _surface_hit(from, direction) + if not hit.is_empty(): + var pixel: Vector2i = hit["pixel"] + _command("ui_move", {"x": pixel.x, "y": pixel.y}) + + +func _release_pointer(from: Vector3, direction: Vector3) -> void: + if keyboard_press: + keyboard_press = false + return + if grabbed_crew != null: + _update_crew_grab(from, direction) + if grab_room >= 0: + # The initial selection may still be queued in FTL. If the bridge + # repeats that selection, retain the press-time Shift grouping. + _command("move_crew", {"crew_id": grab_crew_id, "room_id": grab_room, + "modifiers": {"shift": grab_shift_down, "control": control_down}}) + _end_crew_grab() + return + if held_surface != null: + var pixel: Variant = held_surface.ray_pixel(from, direction) + if pixel != null: + held_pixel = pixel + _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) + 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"}) + else: + _command("cancel", {}) + beam_drag = false + + +func _secondary_click(from: Vector3, direction: Vector3) -> void: + if rename_keyboard.visible: + _command("text_input", {"entry_id": rename_keyboard.entry.get("id", ""), "op": "cancel"}) + return + _end_crew_grab() + var hit := _surface_hit(from, direction) + if not hit.is_empty(): + var pixel: Vector2i = hit["pixel"] + _command("ui_mouse", {"x": pixel.x, "y": pixel.y, "button": "right"}) + else: + _command("cancel", {}) + crew_selected = false + crew_id_selected = "" + + +func _selected_weapon_is_beam() -> bool: + var slot := int(hud_state.get("weapon_selected", -1)) + var weapons: Array = hud_state.get("player", {}).get("weapons", []) + return slot >= 0 and slot < weapons.size() and str(weapons[slot].get("kind", "")).to_lower() == "beam" + + +func _active_target() -> Dictionary: + var targeting: Dictionary = hud_state.get("targeting", {}) + if targeting.get("active", false): + return targeting + if not hud_state.has("targeting") and weapon_selected and battle: + return {"active": true, "kind": "weapon", "ships": ["enemy"]} + return {} + + +func _target_room_hit(from: Vector3, direction: Vector3) -> Dictionary: + var closest := {} + for side in _active_target().get("ships", ["enemy"]): + var hit := _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 _begin_crew_grab(crew: Node3D, from: Vector3) -> void: + _end_crew_grab() + grabbed_crew = crew + grab_ship = str(crew.get_meta("ship", "player")) + grab_crew_id = str(crew.get_meta("crew_id")) + grab_shift_down = shift_down + grab_distance = clampf(from.distance_to(crew.global_position), 0.08, 3.0) + # This is a visual preview. The native crew remains at its real position and + # walks normally after the drop order; moving the hand never teleports it. + crew_preview = crew.duplicate() as Node3D + crew_preview.set_process(false) + crew_preview.set_physics_process(false) + _disable_preview_collisions(crew_preview) + add_child(crew_preview) + crew_preview.global_transform = crew.global_transform + grab_label.visible = true + grab_label.text = "GRAB • release over a room\nB: cancel" + + +func _disable_preview_collisions(node: Node) -> void: + if node is CollisionObject3D: + node.collision_layer = 0 + node.collision_mask = 0 + if node is CollisionShape3D: + node.disabled = true + for child in node.get_children(): + _disable_preview_collisions(child) + + +func _room_hit(from: Vector3, direction: Vector3, side: String = "", margin: float = 0.0) -> Dictionary: + var closest := {} + for ship in [player_ship, enemy_ship]: + if not ship.is_visible_in_tree() or (side != "" and side != ("enemy" if ship == enemy_ship else "player")): + continue + var hit: Dictionary = ship.room_ray_hit(from, direction, margin) + if not hit.is_empty() and (closest.is_empty() or hit.distance < closest.distance): + closest = hit + return closest + + +func _update_crew_grab(from: Vector3, direction: Vector3) -> void: + if grabbed_crew == null: + return + if not is_instance_valid(grabbed_crew): + _end_crew_grab() + return + grab_room = -1 + var hit := _room_hit(from, direction, grab_ship, 0.012) + var drop_ship: Node3D = player_ship if grab_ship == "player" else enemy_ship + var point := from + direction * grab_distance + 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(): + grab_room = int(hit["collider"].get_meta("room_id")) + point = hit.get("room_position", hit["position"]) + drop_ship.global_basis.y.normalized() * 0.025 + var role := str(drop_ship.room_nodes[grab_room].get_meta("system_role", "")) + grab_label.text = "DROP • %s\nRelease trigger • B: cancel" % (role.to_upper() if role != "" else "ROOM") + else: + grab_label.text = "GRAB • aim at a room on this ship\nRelease to keep selection • B: cancel" + if drop_ship.has_method("set_drop_target"): + drop_ship.set_drop_target(grab_room) + crew_preview.global_position = point + grab_label.global_position = point + Vector3(0, 0.08, 0) + _face_front(grab_label) + + +func _end_crew_grab() -> void: + for ship in [player_ship, enemy_ship]: + if ship != null and ship.has_method("set_drop_target"): + ship.set_drop_target(-1) + if is_instance_valid(crew_preview): + crew_preview.queue_free() + crew_preview = null + grabbed_crew = null + grab_crew_id = "" + grab_room = -1 + if grab_label != null: + grab_label.visible = false + + +func _recenter() -> void: + tabletop_root.transform = Transform3D.IDENTITY + tabletop_root.scale = Vector3.ONE * 0.35 + if xr_active: + var head := camera.get_camera_transform().origin + # Local/head-relative XR spaces can put the head at Y=0. There is no + # virtual floor, so keep the ship below the head in either reference space. + var forward := -camera.get_camera_transform().basis.z + forward.y = 0.0 + if forward.length_squared() > 0.001: + forward = forward.normalized() + tabletop_root.global_position = head + forward * 0.85 - Vector3(0, 0.95, 0) + tabletop_root.rotation.y = atan2(-forward.x, -forward.z) + else: + tabletop_root.global_position = head + Vector3(0, -0.95, -0.85) + else: + tabletop_root.position.y = 0.7 + + +func _local_data_path(file_name: String) -> String: + if OS.has_feature("editor"): + return ProjectSettings.globalize_path("res://local_game_data/%s" % file_name) + return OS.get_executable_path().get_base_dir().path_join("local_game_data").path_join(file_name) + + +func _set_hazard(kind: String) -> void: + hazard = kind + space_environment.set_hazard(kind) + if hud_surface != null: + _update_hud() + + +func _material(color: Color) -> StandardMaterial3D: + var mat := StandardMaterial3D.new() + mat.albedo_color = color + if color.a < 1.0: + mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA + return mat + + +func _emissive(color: Color, strength: float) -> StandardMaterial3D: + var mat := _material(color) + mat.emission_enabled = true + mat.emission = color + mat.emission_energy_multiplier = strength + return mat diff --git a/scripts/main.gd.uid b/scripts/main.gd.uid new file mode 100644 index 0000000..e15802a --- /dev/null +++ b/scripts/main.gd.uid @@ -0,0 +1 @@ +uid://0j15khj8bggh diff --git a/scripts/pose_filter.gd b/scripts/pose_filter.gd new file mode 100644 index 0000000..8b5a2da --- /dev/null +++ b/scripts/pose_filter.gd @@ -0,0 +1,35 @@ +extends RefCounted + +var position_deadband := 0.0015 +var angle_deadband := deg_to_rad(0.15) +var time_constant := 0.075 +var fast_time_constant := 0.025 +var initialized := false +var value := Transform3D.IDENTITY + + +func reset() -> void: + initialized = false + + +func update(target: Transform3D, delta: float) -> Transform3D: + var rotation := target.basis.orthonormalized().get_rotation_quaternion() + if not initialized: + value = target + initialized = true + return value + var current := value.basis.orthonormalized().get_rotation_quaternion() + var distance := value.origin.distance_to(target.origin) + var angle := current.angle_to(rotation) + # Reacquired tracking and explicit recentering must not leave a long trail. + if distance > 0.35 or angle > deg_to_rad(70.0): + value = target + return value + var moving := distance > 0.025 or angle > deg_to_rad(8.0) + var alpha := 1.0 - exp(-maxf(delta, 0.0) / (fast_time_constant if moving else time_constant)) + if distance > position_deadband: + value.origin = value.origin.lerp(target.origin, alpha) + if angle > angle_deadband: + current = current.slerp(rotation, alpha) + value.basis = Basis(current).scaled(target.basis.get_scale()) + return value diff --git a/scripts/rename_keyboard.gd b/scripts/rename_keyboard.gd new file mode 100644 index 0000000..4a90e38 --- /dev/null +++ b/scripts/rename_keyboard.gd @@ -0,0 +1,144 @@ +extends Node3D + +# One rendered surface, with geometric key picking. Text remains authoritative +# in FTL; the keyboard only forwards scoped native text events. +const SIZE := Vector2(0.86, 0.39) +const PIXELS := Vector2(1000, 454) +const UI = preload("res://scripts/ftl_ui_theme.gd") +var entry: Dictionary = {} +var upper := true +var keys: Array = [] +var viewport: SubViewport +var canvas: Control +var title: Label +var value_label: Label +var hint: Label + + +func _ready() -> void: + viewport = SubViewport.new() + viewport.size = Vector2i(PIXELS) + viewport.transparent_bg = true + viewport.render_target_update_mode = SubViewport.UPDATE_ONCE + add_child(viewport) + canvas = Control.new() + canvas.size = PIXELS + canvas.texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST + viewport.add_child(canvas) + var background := ColorRect.new() + background.size = PIXELS + background.color = UI.DARK + canvas.add_child(background) + title = _label(Vector2(20, 5), 29, "RENAME") + title.add_theme_font_override("font",UI.ASSETS.font("header")) + value_label = _label(Vector2(20, 44), 38, "") + hint = _label(Vector2(20, 417), 28, "Right trigger: type B: cancel Native name length applies") + var rows := ["1234567890", "QWERTYUIOP", "ASDFGHJKL", "ZXCVBNM-_"] + for row in range(rows.size()): + var line: String = rows[row] + var left := (PIXELS.x - line.length() * 94.0) * 0.5 + for column in range(line.length()): + _key(Rect2(left + column * 94.0, 100 + row * 61, 88, 55), line[column], "insert", line[column]) + _key(Rect2(30, 350, 100, 58), "SHIFT", "shift", "") + _key(Rect2(140, 350, 230, 58), "SPACE", "insert", " ") + _key(Rect2(380, 350, 100, 58), "CLEAR", "clear", "") + _key(Rect2(490, 350, 140, 58), "BACK", "backspace", "") + _key(Rect2(640, 350, 150, 58), "CANCEL", "cancel", "") + _key(Rect2(800, 350, 170, 58), "DONE", "confirm", "") + var mesh := MeshInstance3D.new() + var quad := QuadMesh.new() + quad.size = SIZE + mesh.mesh = quad + var material := StandardMaterial3D.new() + material.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED + material.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA + material.no_depth_test = true + material.render_priority = 124 + material.texture_filter = BaseMaterial3D.TEXTURE_FILTER_NEAREST + material.albedo_texture = viewport.get_texture() + mesh.material_override = material + add_child(mesh) + visible = false + + +func _label(point: Vector2, font_size: int, text: String) -> Label: + var label := Label.new() + label.position = point + label.add_theme_font_override("font",UI.ASSETS.font("body")) + label.add_theme_font_size_override("font_size", font_size) + label.add_theme_color_override("font_color",UI.INK) + label.text = text + canvas.add_child(label) + return label + + +func _key(rect: Rect2, text: String, op: String, character: String) -> void: + var plate := Panel.new() + plate.position = rect.position + plate.size = rect.size + var style := StyleBoxFlat.new() + style.bg_color = UI.PANEL if op != "confirm" else UI.SELECTED + style.border_color = UI.EDGE if op != "confirm" else UI.GREEN + style.set_border_width_all(1) + plate.add_theme_stylebox_override("panel",style) + canvas.add_child(plate) + var label := _label(rect.position + Vector2(0,4),32,text) + label.size = rect.size - Vector2(0, 6) + label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + keys.append({"rect": rect, "op": op, "text": character, "label": label}) + + +func set_entry(value: Dictionary) -> void: + if value == entry: + return + var first := str(value.get("id", "")) != str(entry.get("id", "")) + entry = value.duplicate(true) + visible = bool(entry.get("active", false)) + if first: + upper = true + _update_case() + title.text = "RENAME %s" % str(entry.get("kind", "NAME")).to_upper() + var text := str(entry.get("text", "")) + var cursor := clampi(int(entry.get("cursor", text.length())), 0, text.length()) + value_label.text = text.substr(0, cursor) + "|" + text.substr(cursor) + viewport.render_target_update_mode = SubViewport.UPDATE_ONCE + + +func _update_case() -> void: + for key in keys: + if key.op == "insert" and key.text != " ": + key.label.text = str(key.text).to_upper() if upper else str(key.text).to_lower() + viewport.render_target_update_mode = SubViewport.UPDATE_ONCE + + +func ray_hit(from: Vector3, direction: Vector3) -> Dictionary: + if not is_visible_in_tree(): + return {} + var start := to_local(from) + var ray := to_local(from + direction) - start + if absf(ray.z) < 0.00001: + return {} + var distance := -start.z / ray.z + if distance <= 0: + return {} + var local_point := start + ray * distance + var pixel := Vector2(local_point.x / SIZE.x + 0.5, 0.5 - local_point.y / SIZE.y) * PIXELS + if not Rect2(Vector2.ZERO, PIXELS).has_point(pixel): + return {} + var hit := {"position": to_global(local_point), "op": ""} + for key in keys: + if key.rect.has_point(pixel): + hit.op = key.op + hit.text = str(key.text).to_upper() if upper else str(key.text).to_lower() + break + return hit + + +func activate(hit: Dictionary) -> Dictionary: + if hit.get("op", "") == "shift": + upper = not upper + _update_case() + return {} + if hit.get("op", "") == "": + return {} + return {"entry_id": entry.get("id", ""), "op": hit.op, "text": hit.get("text", "")} diff --git a/scripts/room_hazards.gd b/scripts/room_hazards.gd new file mode 100644 index 0000000..c96343b --- /dev/null +++ b/scripts/room_hazards.gd @@ -0,0 +1,103 @@ +extends Node3D + +const Voxels = preload("res://scripts/voxel_mesh.gd") +const MAX_HAZARDS := 128 +static var shared_visuals: Dictionary = {} +var flame_outer: MultiMeshInstance3D +var flame_inner: MultiMeshInstance3D +var breaches: MultiMeshInstance3D +var air_streams: MultiMeshInstance3D +var fire_points: Array[Vector3] = [] +var breach_points: Array[Vector3] = [] +var animation_time := 0.0 +var native_signature := "" + + +func build() -> void: + # Shared meshes and materials: no particles, lights, or per-frame mesh rebuilds. + flame_outer = _batch("FireOuter", [ + Voxels.block(Vector3(0, 0.016, 0), Vector3(0.074, 0.032, 0.061), Color("df4225")), + Voxels.block(Vector3(0.010, 0.049, 0.005), Vector3(0.051, 0.035, 0.043), Color("ff842f")), + Voxels.block(Vector3(-0.008, 0.079, 0), Vector3(0.024, 0.027, 0.026), Color("ffb64a")), + Voxels.block(Vector3(0.020, 0.098, 0.004), Vector3(0.015, 0.020, 0.013), Color("ffd978"))], true) + flame_inner = _batch("FireInner", [ + Voxels.block(Vector3(0, 0.019, 0), Vector3(0.038, 0.037, 0.039), Color("ffde80")), + Voxels.block(Vector3(-0.004, 0.045, 0), Vector3(0.019, 0.028, 0.022), Color("fff1b3"))], true) + var breach_blocks := [Voxels.block(Vector3(0, 0.0, 0), Vector3(0.085, 0.003, 0.085), Color("090f17"))] + for i in range(8): + var angle := i * TAU / 8.0 + var radius := 0.046 if i % 2 == 0 else 0.049 + breach_blocks.append(Voxels.block(Vector3(cos(angle) * radius, 0.003, sin(angle) * radius), + Vector3(0.025, 0.011, 0.021), Color("9a8b77") if i % 2 == 0 else Color("566772"))) + breaches = _batch("HullBreaches", breach_blocks, false) + air_streams = _batch("BreachAir", [ + Voxels.block(Vector3(-0.018, 0.029, 0), Vector3(0.004, 0.040, 0.004), Color("6c9caa")), + Voxels.block(Vector3(0.008, 0.044, -0.010), Vector3(0.004, 0.035, 0.004), Color("9ac4cc")), + Voxels.block(Vector3(0.016, 0.063, 0.014), Vector3(0.004, 0.029, 0.004), Color("6c9caa"))], true) + + +func _batch(node_name: String, blocks: Array, emissive: bool) -> MultiMeshInstance3D: + if not shared_visuals.has(node_name): + var source: MeshInstance3D = Voxels.make(blocks) + var material: StandardMaterial3D = source.material_override + material.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED + material.emission_enabled = emissive + material.emission = Color("ffab60") if node_name.begins_with("Fire") else Color("527a8b") + material.emission_energy_multiplier = 0.22 + shared_visuals[node_name] = {"mesh": source.mesh, "material": material} + source.free() + var batch := MultiMeshInstance3D.new() + batch.name = node_name + batch.multimesh = MultiMesh.new() + batch.multimesh.transform_format = MultiMesh.TRANSFORM_3D + batch.multimesh.mesh = shared_visuals[node_name].mesh + batch.material_override = shared_visuals[node_name].material + batch.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF + add_child(batch) + return batch + + +func set_live(ship: Node3D, data: Dictionary) -> void: + var fires: Array[Vector3] = [] + var holes: Array[Vector3] = [] + for room in data.get("rooms", []): + if bool(ship.enemy) and (not bool(ship.inspect_rooms) or not bool(room.get("visible", false))): + continue + for point in room.get("fire_tiles", []): + if fires.size() < MAX_HAZARDS: + fires.append(ship.pixel_point(point, 0.082)) + for point in room.get("breach_tiles", []): + if holes.size() < MAX_HAZARDS: + holes.append(ship.pixel_point(point, 0.082)) + var signature := str(fires) + ":" + str(holes) + if signature == native_signature: + return + native_signature = signature + fire_points = fires + breach_points = holes + flame_outer.multimesh.instance_count = fires.size() + flame_inner.multimesh.instance_count = fires.size() + breaches.multimesh.instance_count = holes.size() + air_streams.multimesh.instance_count = holes.size() + for i in range(holes.size()): + breaches.multimesh.set_instance_transform(i, Transform3D(Basis().rotated(Vector3.UP, (i % 4) * PI / 2.0), holes[i])) + _render_animation() + + +func animate(delta: float) -> void: + if fire_points.is_empty() and breach_points.is_empty(): + return + animation_time += delta + _render_animation() + + +func _render_animation() -> void: + for i in range(fire_points.size()): + var phase := animation_time * 8.5 + i * 2.7 + var outer := Basis().rotated(Vector3.UP, (i % 4) * PI / 2.0) + outer = Basis(outer.x * (0.92 + sin(phase) * 0.07), outer.y * (0.87 + sin(phase + 0.8) * 0.16), outer.z) + flame_outer.multimesh.set_instance_transform(i, Transform3D(outer, fire_points[i])) + flame_inner.multimesh.set_instance_transform(i, Transform3D(Basis().scaled(Vector3(1.0, 0.88 + sin(phase + 1.7) * 0.18, 1.0)), fire_points[i])) + for i in range(breach_points.size()): + var phase := fposmod(animation_time * 0.7 + i * 0.19, 1.0) + air_streams.multimesh.set_instance_transform(i, Transform3D(Basis().scaled(Vector3(1.0, 0.6 + phase * 0.6, 1.0)), breach_points[i] + Vector3.UP * phase * 0.025)) diff --git a/scripts/shield.gdshader b/scripts/shield.gdshader new file mode 100644 index 0000000..ef4d425 --- /dev/null +++ b/scripts/shield.gdshader @@ -0,0 +1,15 @@ +shader_type spatial; +render_mode unshaded, blend_mix, cull_back, depth_draw_never; + +uniform vec4 shield_color : source_color = vec4(0.18, 0.58, 1.0, 1.0); +uniform float strength = 0.5; +uniform float hit_flash = 0.0; + +void fragment() { + float edge = pow(1.0 - abs(dot(normalize(NORMAL), normalize(VIEW))), 2.4); + vec2 grid = abs(fract(UV * vec2(40.0, 22.0)) - 0.5); + float line = smoothstep(0.45, 0.49, max(grid.x, grid.y)); + ALBEDO = shield_color.rgb; + EMISSION = shield_color.rgb * (0.5 + hit_flash * 2.0); + ALPHA = (0.025 + edge * 0.3 + line * 0.035) * strength + hit_flash * 0.2; +} diff --git a/scripts/shield.gdshader.uid b/scripts/shield.gdshader.uid new file mode 100644 index 0000000..c664b1b --- /dev/null +++ b/scripts/shield.gdshader.uid @@ -0,0 +1 @@ +uid://bnlk3ceim4cxm diff --git a/scripts/ship_model.gd b/scripts/ship_model.gd new file mode 100644 index 0000000..893e839 --- /dev/null +++ b/scripts/ship_model.gd @@ -0,0 +1,803 @@ +extends Node3D + +const TILE := 0.18 +const CrewModel = preload("res://scripts/voxel_crew.gd") +const WeaponModel = preload("res://scripts/voxel_weapon.gd") +const DroneModel = preload("res://scripts/voxel_drone.gd") +const Voxels = preload("res://scripts/voxel_mesh.gd") +const RoomHazards = preload("res://scripts/room_hazards.gd") +const UiAssets = preload("res://scripts/ftl_ui_assets.gd") +static var system_icon_textures: Dictionary = {} +static var door_leaf_meshes: Dictionary = {} +var ship_name := "" +var enemy := false +var layout_data: Dictionary = {} +var layout_center := Vector2.ZERO +var room_points: Dictionary = {} +var mount_points: Array[Vector3] = [] +var shield_shell: MeshInstance3D +var shield_radii := Vector3(1.8, 0.48, 1.1) +var shield_center := Vector3.ZERO +var shield_charge := 2 +var shield_flash := 0.0 +var simulation_paused := false +var crew_nodes: Dictionary = {} +var room_nodes: Dictionary = {} +var room_bounds: Dictionary = {} +var room_visibility: Dictionary = {} +var weapon_nodes: Array[Node3D] = [] +var door_nodes: Dictionary = {} +var drone_nodes: Dictionary = {} +var drop_target_room := -1 +var hull_voxel_count := 0 +var shield_fit_points := PackedVector3Array() +var shield_fit_padding := 1.0 +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 room_hazards: Node3D +var viewer_position := Vector3.ZERO +var viewer_position_valid := false + + +func build(name: String, is_enemy: bool) -> void: + for child in get_children(): + remove_child(child) + child.queue_free() + room_points.clear() + mount_points.clear() + room_nodes.clear() + room_bounds.clear() + room_visibility.clear() + crew_nodes.clear() + weapon_nodes.clear() + door_nodes.clear() + drone_nodes.clear() + drop_target_room = -1 + hull_voxel_count = 0 + shield_fit_points.clear() + shield_fit_padding = 1.0 + demo_crew_marker = null + native_shield_geometry.clear() + shield_state_cache = Vector2i(-1, -1) + ship_name = name + enemy = is_enemy + var path := _data_path("%s.json" % name) + var layout: Dictionary = {} + if FileAccess.file_exists(path): + var parsed: Variant = JSON.parse_string(FileAccess.get_file_as_string(path)) + if parsed is Dictionary: + layout = parsed + if layout.is_empty(): + layout = _placeholder_layout() + var rooms: Array = layout.get("rooms", []) + var min_x := 10000.0 + var min_y := 10000.0 + var max_x := -10000.0 + var max_y := -10000.0 + for room in rooms: + min_x = minf(min_x, float(room["x"])) + min_y = minf(min_y, float(room["y"])) + max_x = maxf(max_x, float(room["x"] + room["w"])) + max_y = maxf(max_y, float(room["y"] + room["h"])) + var center := Vector2((min_x + max_x) / 2.0, (min_y + max_y) / 2.0) + layout_data = layout + layout_center = center + _make_hull(layout, center) + for room in rooms: + _make_room(room, center, str(layout.get("systems", {}).get(str(int(room["id"])), ""))) + if not enemy: + _make_crew(rooms, center) + _make_weapon_mounts(layout, center) + _make_shield(layout) + room_hazards = RoomHazards.new() + room_hazards.name = "RoomHazards" + room_hazards.build() + add_child(room_hazards) + + +func _placeholder_layout() -> Dictionary: + var rooms: Array = [] + for x in range(3): + for y in range(2): + rooms.append({"id": x * 2 + y, "x": x * 2, "y": y * 2, "w": 2, "h": 2}) + return {"rooms": rooms} + + +func _make_hull(layout: Dictionary, center: Vector2) -> void: + var rect: Dictionary = layout.get("image_rect", {}) + 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.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)) + add_child(block) + return + var image := Image.load_from_file(texture_path) + if image == null: + return + _make_hull_depth(image, rect, center) + var quad := QuadMesh.new() + quad.size = Vector2(float(rect["w"]) / 35.0 * TILE, float(rect["h"]) / 35.0 * TILE) + var mesh := MeshInstance3D.new() + 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) + mesh.rotation_degrees.x = -90.0 + var mat := StandardMaterial3D.new() + mat.albedo_texture = ImageTexture.create_from_image(image) + mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA + mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED + mat.texture_filter = BaseMaterial3D.TEXTURE_FILTER_NEAREST + mat.cull_mode = BaseMaterial3D.CULL_DISABLED + mesh.material_override = mat + add_child(mesh) + + +func _make_hull_depth(image: Image, rect: Dictionary, center: Vector2) -> void: + # Extrude the owned silhouette in colored, stepped armor. A MultiMesh keeps + # thousands of hull blocks to one draw call per ship, with no external model. + var step := 7 + # Map the extrusion through the same native image rectangle as the deck. + # This also handles archives whose only available sprite is a cropped image. + var image_scale := Vector2(float(rect["w"]) / image.get_width(), float(rect["h"]) / image.get_height()) + var opaque_cells: Array = [] + for py in range(0, image.get_height(), step): + for px in range(0, image.get_width(), step): + var cell_size := Vector2(mini(step, image.get_width() - px), mini(step, image.get_height() - py)) + var sample_x := mini(px + step / 2, image.get_width() - 1) + var sample_y := mini(py + step / 2, image.get_height() - 1) + var color := image.get_pixel(sample_x, sample_y) + if color.a < 0.35: + continue + var edge := false + for offset in [Vector2i(-step * 2, 0), Vector2i(step * 2, 0), Vector2i(0, -step * 2), Vector2i(0, step * 2)]: + var sx: int = sample_x + offset.x + var sy: int = sample_y + offset.y + if sx < 0 or sy < 0 or sx >= image.get_width() or sy >= image.get_height() or image.get_pixel(sx, sy).a < 0.35: + edge = true + var height := 0.19 if edge else 0.32 + # Bright armor plates become a separate lower keel tier; the textured + # deck remains above it and room floors retain their original layout. + height += 0.035 if color.get_luminance() > 0.55 and not edge else 0.0 + var native_cell := cell_size * image_scale + var point := Vector3((float(rect["x"]) + (px + cell_size.x * 0.5) * image_scale.x) / 35.0 * TILE - center.x * TILE, + -height * 0.5 - 0.005, (float(rect["y"]) + (py + cell_size.y * 0.5) * image_scale.y) / 35.0 * TILE - center.y * TILE) + var half_size := native_cell / 35.0 * TILE * 0.5 + for x_sign in [-1.0, 1.0]: + for y_sign in [-1.0, 1.0]: + for z_sign in [-1.0, 1.0]: + shield_fit_points.append(point + Vector3(x_sign * half_size.x, y_sign * height * 0.5, z_sign * half_size.y)) + color = color.darkened(0.27) + color.a = 1.0 + opaque_cells.append([point, height, color, native_cell / 35.0 * TILE]) + var block := BoxMesh.new() + block.size = Vector3.ONE + var multi := MultiMesh.new() + multi.transform_format = MultiMesh.TRANSFORM_3D + multi.use_colors = true + multi.mesh = block + multi.instance_count = opaque_cells.size() + for i in range(opaque_cells.size()): + multi.set_instance_transform(i, Transform3D(Basis().scaled(Vector3(opaque_cells[i][3].x, opaque_cells[i][1], opaque_cells[i][3].y)), opaque_cells[i][0])) + multi.set_instance_color(i, opaque_cells[i][2]) + var hull := MultiMeshInstance3D.new() + hull.name = "ExtrudedHull" + hull.multimesh = multi + var material := _material(Color.WHITE) + material.vertex_color_use_as_albedo = true + material.roughness = 0.82 + hull.material_override = material + hull_voxel_count = opaque_cells.size() + add_child(hull) + + +func _make_room(room: Dictionary, center: Vector2, system_name: String) -> void: + var width := float(room["w"]) * TILE + var depth := float(room["h"]) * TILE + var location := Vector3((float(room["x"]) + float(room["w"]) / 2.0 - center.x) * TILE, + 0.065, (float(room["y"]) + float(room["h"]) / 2.0 - center.y) * TILE) + room_points[int(room["id"])] = location + Vector3(0.0, 0.12, 0.0) + var area := Area3D.new() + area.set_meta("kind", "room") + area.set_meta("ship", "enemy" if enemy else "player") + area.set_meta("room_id", int(room["id"])) + area.position = location + add_child(area) + room_nodes[int(room["id"])] = area + room_bounds[int(room["id"])] = Rect2(Vector2(location.x - width * 0.5, location.z - depth * 0.5), Vector2(width, depth)) + for x_sign in [-1.0, 1.0]: + for z_sign in [-1.0, 1.0]: + shield_fit_points.append(location + Vector3(x_sign * width * 0.5, 0.125, z_sign * depth * 0.5)) + var shape := CollisionShape3D.new() + var box := BoxShape3D.new() + box.size = Vector3(width * 0.97, 0.025, depth * 0.97) + shape.shape = box + area.add_child(shape) + var floor := MeshInstance3D.new() + floor.name = "Floor" + var floor_mesh := BoxMesh.new() + floor_mesh.size = Vector3(width * 0.97, 0.025, depth * 0.97) + floor.mesh = floor_mesh + floor.material_override = _material(Color(0.31, 0.35, 0.45) if enemy else Color(0.73, 0.74, 0.69)) + area.add_child(floor) + _make_room_walls(room, area) + _set_room_role(area, system_name) + + +func _make_room_walls(room: Dictionary, area: Area3D) -> void: + var blocks: Array = [] + var color := Color("573b32") if enemy else Color("344450") + for side in range(4): + var horizontal := side < 2 + var count := int(room["w"] if horizontal else room["h"]) + for tile in range(count): + var grid_x := int(room["x"]) + tile if horizontal else int(room["x"]) + (int(room["w"]) if side == 3 else 0) + var grid_y := int(room["y"]) + (int(room["h"]) if side == 1 else 0) if horizontal else int(room["y"]) + tile + var doorway := false + for door in layout_data.get("doors", []): + if int(door["x"]) == grid_x and int(door["y"]) == grid_y and bool(door["vertical"]) != horizontal: + doorway = true + var point := Vector3((grid_x + (0.5 if horizontal else 0.0) - layout_center.x) * TILE, + 0.065, (grid_y + (0.0 if horizontal else 0.5) - layout_center.y) * TILE) - area.position + point.y = 0.063 + if doorway: + for offset in [-1.0, 1.0]: + var jamb := point + (Vector3(offset * 0.077, 0, 0) if horizontal else Vector3(0, 0, offset * 0.077)) + blocks.append(Voxels.block(jamb, Vector3(0.025, 0.10, 0.014) if horizontal else Vector3(0.014, 0.10, 0.025), color)) + else: + blocks.append(Voxels.block(point, Vector3(TILE, 0.10, 0.014) if horizontal else Vector3(0.014, 0.10, TILE), color)) + blocks.append(Voxels.block(point + Vector3(0, 0.054, 0), Vector3(TILE, 0.01, 0.021) if horizontal else Vector3(0.021, 0.01, TILE), color.lightened(0.15))) + var walls := Voxels.make(blocks) + walls.name = "Walls" + area.add_child(walls) + + +func _set_room_role(area: Area3D, role: String) -> void: + if area.get_meta("system_role", "__unset") == role: + return + area.set_meta("system_role", role) + for node_name in ["SystemIcon", "SystemRole"]: + var old := area.get_node_or_null(node_name) + if old != null: + area.remove_child(old) + old.queue_free() + if role.is_empty(): + return + var path := _data_path("ui/img/icons/s_%s.png" % role) + if FileAccess.file_exists(path): + if not system_icon_textures.has(path): + var image := Image.load_from_file(path) + if image != null: + system_icon_textures[path] = ImageTexture.create_from_image(image) + if system_icon_textures.has(path): + var icon := MeshInstance3D.new() + icon.name = "SystemIcon" + var quad := QuadMesh.new() + quad.size = Vector2(0.12, 0.12) + icon.mesh = quad + icon.rotation.x = -PI / 2.0 + icon.position.y = 0.022 + var material := StandardMaterial3D.new() + material.albedo_texture = system_icon_textures[path] + material.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA + material.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED + material.texture_filter = BaseMaterial3D.TEXTURE_FILTER_NEAREST + material.cull_mode = BaseMaterial3D.CULL_DISABLED + material.albedo_color = Color("fff2c4") if enemy else Color("223844") + icon.material_override = material + area.add_child(icon) + var label := Label3D.new() + label.name = "SystemRole" + label.font = UiAssets.font("body") + label.outline_size = 0 + var names := {"pilot": "PILOT", "weapons": "WEAPONS", "shields": "SHIELDS", "engines": "ENGINES", + "oxygen": "OXYGEN", "medbay": "MEDBAY", "doors": "DOORS", "sensors": "SENSORS", "drones": "DRONES", + "teleporter": "TELEPORT", "cloaking": "CLOAK", "artillery": "ARTILLERY", "battery": "BATTERY", + "clonebay": "CLONE BAY", "mind": "MIND", "hacking": "HACKING"} + label.text = str(names.get(role, role.to_upper())) + label.font_size = 32 + label.texture_filter = BaseMaterial3D.TEXTURE_FILTER_NEAREST + label.pixel_size = 0.00075 + label.modulate = Color("fff2c4") if enemy else Color("223844") + label.position = Vector3(0, 0.023, 0.09) + label.rotation.x = -PI / 2.0 + area.add_child(label) + + +func set_drop_target(room_id: int = -1) -> void: + if drop_target_room == room_id: + return + drop_target_room = room_id + for id in room_nodes: + var floor: MeshInstance3D = room_nodes[id].get_node("Floor") + floor.material_override.albedo_color = Color(0.35, 0.95, 0.71) if id == room_id else floor.get_meta("normal_tint", Color(0.65, 0.7, 0.73)) + + +func room_ray_hit(from: Vector3, direction: Vector3, margin_m: float = 0.0) -> Dictionary: + # Test the floor the player sees, rather than the taller crew/door colliders. + # The ray parameter remains a WORLD distance through scaled ship transforms. + if not is_visible_in_tree() or direction.length_squared() < 0.000001: + return {} + var normalized := direction.normalized() + var local_from := to_local(from) + var local_direction := global_basis.inverse() * 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 floor_point := Vector2(point.x, point.z) + var best_id := -1 + var best_edge_distance := INF + for id in room_bounds: + var rect: Rect2 = room_bounds[id] + if rect.has_point(floor_point): + best_id = id + best_edge_distance = 0.0 + break + if margin_m <= 0.0: + continue + var closest := floor_point.clamp(rect.position, rect.end) + var closest_world := to_global(Vector3(closest.x, 0.0775, closest.y)) + var edge_distance := closest_world.distance_to(to_global(point)) + if edge_distance <= margin_m and edge_distance < best_edge_distance: + best_id = id + best_edge_distance = edge_distance + if best_id < 0: + return {} + var selected_rect: Rect2 = room_bounds[best_id] + # Keep rounded native mouse coordinates one pixel inside the chosen room. + var native_pixel_padding := Vector2.ONE * TILE / 35.0 + var command_point := floor_point.clamp(selected_rect.position + native_pixel_padding, selected_rect.end - native_pixel_padding) + return {"collider": room_nodes[best_id], "position": to_global(point), "distance": distance, + "room_id": best_id, "ship": "enemy" if enemy else "player", "edge_margin": best_edge_distance > 0.0, + "edge_distance": best_edge_distance, "room_position": to_global(Vector3(command_point.x, point.y, command_point.y)), + "pixel": {"x": (command_point.x / TILE + layout_center.x + float(layout_data.get("x_offset", 0))) * 35.0, + "y": (command_point.y / TILE + layout_center.y + float(layout_data.get("y_offset", 0))) * 35.0}} + + +func _apply_doors(doors: Array) -> void: + var present: Dictionary = {} + for data in doors: + var id := str(data["id"]) + present[id] = true + if not door_nodes.has(id): + var area := Area3D.new() + area.set_meta("kind", "door") + area.set_meta("door_id", data["id"]) + area.set_meta("ship", "enemy" if enemy else "player") + var collider := CollisionShape3D.new() + var shape := BoxShape3D.new() + shape.size = Vector3(0.15, 0.15, 0.04) + collider.shape = shape + area.add_child(collider) + add_child(area) + door_nodes[id] = area + var door: Area3D = door_nodes[id] + # iBlast reports the actual native blast-door strength, including crew + # bonuses. Old snapshots can fall back to the installed doors level. + var tier := clampi(int(data.get("blast", maxi(0, int(data.get("level", live_data.get("door_level", 1))) - 1))), 0, 3) + if door.get_meta("blast_tier", -1) != tier: + door.set_meta("blast_tier", tier) + door.remove_meta("tint") + for side in [-1.0, 1.0]: + var leaf_name := "Left" if side < 0 else "Right" + var previous := door.get_node_or_null(leaf_name) + if previous != null: + door.remove_child(previous) + previous.queue_free() + var leaf := _make_door_leaf(tier) + leaf.name = leaf_name + leaf.position.x = side * (0.031 + float(door.get_meta("opening", 0.0)) * 0.065) + door.add_child(leaf) + door.position = pixel_point(data, 0.13) + door.rotation.y = PI / 2.0 if bool(data.get("vertical", false)) else 0.0 + door.visible = not enemy or (inspect_rooms and (_room_visible(int(data.get("room_a", -1))) or _room_visible(int(data.get("room_b", -1))))) + door.collision_layer = 1 if door.visible and not enemy and bool(data.get("controllable", false)) else 0 + door.set_meta("open", bool(data.get("open", false))) + door.set_meta("native", data) + var tint := Color.WHITE + if bool(data.get("hacked", false)): + tint = Color("ac70ca") + elif bool(data.get("locked", false)) or bool(data.get("ionized", false)): + tint = Color("eab166") + elif float(data.get("health", 1)) < float(data.get("max_health", 1)): + tint = Color("ea7166") + if door.get_meta("tint", Color(-1, -1, -1)) != tint: + door.set_meta("tint", tint) + for name in ["Left", "Right"]: + door.get_node(name).material_override.albedo_color = tint + for id in door_nodes.keys(): + if not present.has(id): + door_nodes[id].queue_free() + door_nodes.erase(id) + + +func _make_door_leaf(tier: int) -> MeshInstance3D: + if not door_leaf_meshes.has(tier): + # All native strength tiers fit the same jamb opening. Reinforcement + # remains shallow and inset rather than stacking oversized outer frames. + var blocks := [Voxels.block(Vector3.ZERO, Vector3(0.059, 0.092, 0.015), Color("bac9d0")), + Voxels.block(Vector3(0, -0.035, 0), Vector3(0.057, 0.008, 0.019), Color("6d858d"))] + for z in [-0.0085, 0.0085]: + blocks.append(Voxels.block(Vector3(0, 0.020, z), Vector3(0.033, 0.008, 0.003), Color("4c8792"))) + if tier > 0: + for z in [-0.010, 0.010]: + blocks.append(Voxels.block(Vector3(0, -0.006, z), Vector3(0.047, 0.048, 0.005), Color("82959d"))) + if tier > 1: + for z in [-0.0135, 0.0135]: + for y in [-0.023, 0.007]: + blocks.append(Voxels.block(Vector3(0, y, z), Vector3(0.046, 0.006, 0.003), Color("b0ad92"))) + if tier > 2: + for z in [-0.0135, 0.0135]: + for x in [-0.021, 0.021]: + blocks.append(Voxels.block(Vector3(x, -0.008, z), Vector3(0.004, 0.043, 0.003), Color("b0ad92"))) + blocks.append(Voxels.block(Vector3(0, 0.032, z), Vector3(0.014, 0.005, 0.003), Color("8f9e9c"))) + var source: MeshInstance3D = Voxels.make(blocks) + door_leaf_meshes[tier] = source.mesh + source.free() + var leaf := MeshInstance3D.new() + leaf.mesh = door_leaf_meshes[tier] + var material := StandardMaterial3D.new() + material.vertex_color_use_as_albedo = true + material.roughness = 0.88 + leaf.material_override = material + return leaf + + +func _apply_drones(drones: Array) -> void: + var present: Dictionary = {} + for data in drones: + if not bool(data.get("is_space", false)) or not bool(data.get("deployed", false)) or bool(data.get("dead", false)): + continue + var id := str(data["id"]) + present[id] = true + if not drone_nodes.has(id): + var model := DroneModel.new() + model.build(data) + add_child(model) + drone_nodes[id] = model + model.position = pixel_point(data, 0.3) + model.rotation.y = -deg_to_rad(float(data.get("angle", 0))) + var drone: Node3D = drone_nodes[id] + drone.set_live(data) + # Positions come from the native drone's current space, regrouped by the + # bridge; attack drones therefore orbit the receiver, not their owner. + drone.set_meta("target", pixel_point(data, 0.3)) + # FTL's current_angle wraps at 360 (native CustomDrones.cpp). + drone.set_meta("target_angle", -deg_to_rad(float(data.get("angle", 0)))) + for id in drone_nodes.keys(): + if not present.has(id): + drone_nodes[id].queue_free() + drone_nodes.erase(id) + + +func _make_crew(rooms: Array, center: Vector2) -> void: + if rooms.is_empty(): + return + var room: Dictionary = rooms[min(rooms.size() - 1, 2)] + var marker := _make_miniature({"id": "crew_1", "name": "Crew", "species": "human", "x": 0, "y": 0}) + demo_crew_marker = marker + marker.position = Vector3((float(room["x"]) + 0.5 - center.x) * TILE, 0.19, + (float(room["y"]) + 0.5 - center.y) * TILE) + + +func _make_weapon_mounts(layout: Dictionary, center: Vector2) -> void: + var rect: Dictionary = layout.get("image_rect", {"x": 0, "y": 0}) + for mount in layout.get("weapon_mounts", []): + var point := Vector3((float(rect["x"]) + float(mount["x"])) / 35.0 * TILE - center.x * TILE, + 0.14, (float(rect["y"]) + float(mount["y"])) / 35.0 * TILE - center.y * TILE) + mount_points.append(point) + var gun := WeaponModel.new() + gun.position = point + gun.rotation.y = 0.0 if str(mount.get("rotate", "false")) == "true" else PI / 2.0 + gun.build({"kind": "laser", "powered": false}) + add_child(gun) + weapon_nodes.append(gun) + if mount_points.is_empty(): + mount_points.append(Vector3(0.5, 0.14, 0.0)) + + +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) + shield_center = Vector3(0.0, 0.06, 0.0) + var rect: Dictionary = layout.get("image_rect", {}) + if not rect.is_empty(): + # A preview without live FTL uses the hull center. The live snapshot + # replaces this with GetBaseEllipse; layout ELLIPSE offsets cannot be + # added to the image center because they use the native graph alignment. + shield_center.x = (float(rect["x"]) + float(rect["w"]) / 2.0) / 35.0 * TILE - layout_center.x * TILE + shield_center.z = (float(rect["y"]) + float(rect["h"]) / 2.0) / 35.0 * TILE - layout_center.y * TILE + shield_shell = MeshInstance3D.new() + var sphere := SphereMesh.new() + sphere.radius = 1.0 + sphere.height = 2.0 + sphere.radial_segments = 64 + sphere.rings = 32 + shield_shell.mesh = sphere + shield_radii = _fit_shield_volume(shield_radii) + shield_shell.position = shield_center + shield_shell.scale = shield_radii + var material := ShaderMaterial.new() + material.shader = preload("res://scripts/shield.gdshader") + shield_shell.material_override = material + add_child(shield_shell) + set_shields(2) + + +func set_shields(charge: int, super_shield: int = 0) -> void: + shield_charge = maxi(charge, 0) + if shield_shell == null: + return + var current := Vector2i(shield_charge, super_shield) + if current == shield_state_cache: + return + shield_state_cache = current + shield_shell.visible = 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)) + + +func _apply_shield_shape(shape: Dictionary) -> void: + if shape.is_empty() or shape == native_shield_geometry or shield_shell == null: + return + var center: Dictionary = shape.get("center", {}) + var radius_x := float(shape.get("a", 0)) + var radius_z := float(shape.get("b", 0)) + if center.is_empty() or radius_x <= 0.0 or radius_z <= 0.0: + return + # GetBaseEllipse is in the same native ship-local pixel space as crew, + # rooms and doors. Enemy layout ELLIPSE offsets include native alignment + # adjustments; adding them to the hull image center a second time was wrong. + native_shield_geometry = shape.duplicate(true) + shield_center = pixel_point(center, 0.06) + shield_radii = Vector3(radius_x / 35.0 * TILE, 0.48, radius_z / 35.0 * TILE) + shield_radii = _fit_shield_volume(shield_radii) + shield_shell.position = shield_center + shield_shell.scale = shield_radii + + +func _fit_shield_volume(native_radii: Vector3) -> Vector3: + # Keep the native X/Z center and near-native footprint. The 2D game's + # ellipse needs vertical clearance for the newly extruded keel and walls. + # Measure actual model corners rather than sizing a bubble by ship class. + var max_radial := 0.0 + var minimum_y := INF + var maximum_y := -INF + for point in shield_fit_points: + var delta := point - shield_center + var radial := Vector2(delta.x / native_radii.x, delta.z / native_radii.z).length() + max_radial = maxf(max_radial, radial) + minimum_y = minf(minimum_y, point.y) + maximum_y = maxf(maximum_y, point.y) + if not shield_fit_points.is_empty(): + # Native FTL has no height axis. Center that presentation axis between + # the keel and the deck, with room for miniature heads above the walls. + shield_center.y = (minimum_y + maximum_y + 0.06) * 0.5 + # At most 12% footprint padding: a bad native coordinate transform must + # remain detectable instead of being hidden by an enormous shell. + shield_fit_padding = minf(1.12, maxf(1.035, max_radial * 1.035)) + var fitted := Vector3(native_radii.x * shield_fit_padding, native_radii.y, native_radii.z * shield_fit_padding) + for point in shield_fit_points: + var delta := point - shield_center + var radial_squared := pow(delta.x / fitted.x, 2) + pow(delta.z / fitted.z, 2) + if radial_squared < 0.9999: + var required_height := absf(delta.y) / sqrt(1.0 - radial_squared) + fitted.y = maxf(fitted.y, required_height * 1.02) + return fitted + + +func flash_shield() -> void: + shield_flash = 1.0 + + +func set_viewer_position(world_position: Vector3) -> void: + viewer_position = world_position + viewer_position_valid = true + + +func _process(delta: float) -> void: + if not simulation_paused: + shield_flash = maxf(0.0, shield_flash - delta * 3.0) + if shield_shell != null: + var material: ShaderMaterial = shield_shell.material_override + material.set_shader_parameter("hit_flash", shield_flash) + var local_viewer := to_local(viewer_position) if viewer_position_valid else Vector3.ZERO + for id in crew_nodes: + var miniature: Node3D = crew_nodes[id] + var model: Node3D = miniature.get_node("Model") + if miniature.has_meta("target"): + var movement: Vector3 = Vector3(miniature.get_meta("target")) - miniature.position + miniature.position = miniature.position.lerp(miniature.get_meta("target"), minf(1.0, delta * 18.0)) + if model is CrewModel: + model.face_movement(movement) + if model is CrewModel and viewer_position_valid: + # The inverse ship transform handles rotated, scaled and tilted + # tables. Viewer yaw follows presentation even when FTL is paused. + model.face_viewer(local_viewer - miniature.position, delta) + if not simulation_paused: + model.animate(delta) + if not simulation_paused: + for weapon in weapon_nodes: + weapon.animate(delta) + for drone in drone_nodes.values(): + drone.animate(delta) + if is_instance_valid(room_hazards): + room_hazards.animate(delta) + for drone in drone_nodes.values(): + # Smooth native 10 Hz samples without extrapolating an orbit or target. + drone.position = drone.position.lerp(drone.get_meta("target", drone.position), minf(1.0, delta * 18.0)) + drone.rotation.y = lerp_angle(drone.rotation.y, float(drone.get_meta("target_angle", drone.rotation.y)), minf(1.0, delta * 18.0)) + for door in door_nodes.values(): + var opening := float(door.get_meta("opening", 0.0)) + opening = move_toward(opening, 1.0 if door.get_meta("open", false) else 0.0, delta * 8.0) + door.set_meta("opening", opening) + door.get_node("Left").position.x = -0.031 - opening * 0.065 + door.get_node("Right").position.x = 0.031 + opening * 0.065 + + +func pixel_point(point: Dictionary, height: float = 0.18) -> Vector3: + return Vector3((float(point.get("x", 0)) / 35.0 - float(layout_data.get("x_offset", 0)) - layout_center.x) * TILE, + height, (float(point.get("y", 0)) / 35.0 - float(layout_data.get("y_offset", 0)) - layout_center.y) * TILE) + + +func apply_live(data: Dictionary) -> void: + live_data = data + room_visibility.clear() + for room in data.get("rooms", []): + room_visibility[int(room["id"])] = bool(room.get("visible", false)) + # Replace the demonstration marker with actual crew; ids remain stable per run. + if is_instance_valid(demo_crew_marker): + remove_child(demo_crew_marker) + demo_crew_marker.queue_free() + demo_crew_marker = null + var present: Dictionary = {} + for crew in data.get("crew", []): + var id := str(crew["id"]) + present[id] = true + if not crew_nodes.has(id): + crew_nodes[id] = _make_miniature(crew) + var miniature: Area3D = crew_nodes[id] + miniature.set_meta("target", pixel_point(crew, 0.085)) + miniature.visible = not enemy or (inspect_rooms and _room_visible(int(crew["room"]))) + miniature.set_meta("controllable", bool(crew.get("controllable", false))) + miniature.collision_layer = 1 if miniature.visible and bool(crew.get("controllable", false)) else 0 + var label: Label3D = miniature.get_node("Name") + label.text = str(crew["name"]) + (" *" if crew.get("selected", false) else "") + label.modulate = Color(1.0, 0.4, 0.3) if int(crew.get("owner", 0)) != 0 else Color.WHITE + var model: Node3D = miniature.get_node("Model") + if model is CrewModel: + var race: String = CrewModel.canonical_species(str(crew.get("species", "human"))) + if model.species != race and CrewModel.SPECIES.has(race): + model.build(race) + model.set_state(crew) + else: + model.set_live(crew) + for id in crew_nodes.keys(): + if not present.has(id): + crew_nodes[id].queue_free() + crew_nodes.erase(id) + for room in data.get("rooms", []): + var id := int(room["id"]) + if not room_nodes.has(id): + continue + var area: Area3D = room_nodes[id] + if data.has("room_systems"): + _set_room_role(area, str(data.get("room_systems", {}).get(str(id), ""))) + for visual in area.get_children(): + if visual is VisualInstance3D: + visual.visible = not enemy or inspect_rooms + # Installed systems remain useful targeting information in vanilla + # FTL when enemy crew / oxygen are obscured by the sensor fog. + var floor_mesh: MeshInstance3D = area.get_node("Floor") + var oxygen := float(room.get("oxygen", 100)) / 100.0 + var tint := Color(0.9, 0.22, 0.08) if int(room.get("fires", 0)) > 0 else Color(0.65, 0.7, 0.73).lerp(Color(0.95, 0.38, 0.4), 1.0 - oxygen) + if enemy and not bool(room.get("visible", false)): + tint = Color(0.12, 0.14, 0.17) + floor_mesh.set_meta("normal_tint", tint) + if id == drop_target_room: + tint = Color(0.35, 0.95, 0.71) + if floor_mesh.material_override.albedo_color != tint: + floor_mesh.material_override.albedo_color = tint + var weapons: Array = data.get("weapons", []) + for slot in range(weapon_nodes.size()): + weapon_nodes[slot].visible = slot < weapons.size() + if slot < weapons.size(): + 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]) + weapon.set_live(weapons[slot]) + _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))) + + +func _room_visible(id: int) -> bool: + return bool(room_visibility.get(id, false)) + + +func _make_miniature(crew: Dictionary) -> Area3D: + var miniature := Area3D.new() + miniature.set_meta("kind", "crew") + miniature.set_meta("crew_id", str(crew["id"])) + miniature.set_meta("ship", "enemy" if enemy else "player") + miniature.position = pixel_point(crew, 0.085) + add_child(miniature) + var collision := CollisionShape3D.new() + var hitbox := CapsuleShape3D.new() + hitbox.radius = 0.05 + hitbox.height = 0.18 + collision.shape = hitbox + collision.position.y = 0.075 + miniature.add_child(collision) + var model: Node3D + if bool(crew.get("is_drone", false)): + model = DroneModel.new() + var drone_data := crew.duplicate() + drone_data["is_space"] = false + model.build(drone_data) + else: + model = CrewModel.new() + model.build(str(crew.get("species", "human"))) + model.name = "Model" + miniature.add_child(model) + var label := Label3D.new() + label.name = "Name" + label.font = UiAssets.font("body") + label.outline_size = 0 + label.text = str(crew["name"]) + label.font_size = 32 + label.texture_filter = BaseMaterial3D.TEXTURE_FILTER_NEAREST + label.pixel_size = 0.001 + label.position.y = 0.18 + label.billboard = BaseMaterial3D.BILLBOARD_ENABLED + miniature.add_child(label) + return miniature + + +func weapon_origin(slot: int) -> Vector3: + return to_global(mount_points[posmod(slot, mount_points.size())]) + + +func room_target(room_id: int) -> Vector3: + return to_global(room_points.get(room_id, Vector3(0.0, 0.18, 0.0))) + + +func shield_intercept(from: Vector3, target: Vector3) -> Vector3: + var local_start := (to_local(from) - shield_center) / shield_radii + var local_end := (to_local(target) - shield_center) / shield_radii + var direction := local_end - local_start + var a := direction.dot(direction) + var b := 2.0 * local_start.dot(direction) + var c := local_start.dot(local_start) - 1.0 + var discriminant := b * b - 4.0 * a * c + if a < 0.00001 or discriminant < 0.0: + return target + var t := (-b - sqrt(discriminant)) / (2.0 * a) + if t < 0.0 or t > 1.0: + return target + return from.lerp(target, t) + + +func _material(color: Color) -> StandardMaterial3D: + var mat := StandardMaterial3D.new() + mat.albedo_color = color + if color.a < 1.0: + mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA + return mat + + +func _data_path(file_name: String) -> String: + if OS.has_feature("editor"): + return ProjectSettings.globalize_path("res://local_game_data/%s" % file_name) + return OS.get_executable_path().get_base_dir().path_join("local_game_data").path_join(file_name) diff --git a/scripts/ship_model.gd.uid b/scripts/ship_model.gd.uid new file mode 100644 index 0000000..5f5fdf0 --- /dev/null +++ b/scripts/ship_model.gd.uid @@ -0,0 +1 @@ +uid://dqk6ls4dn358p diff --git a/scripts/shortcut_wheel.gd b/scripts/shortcut_wheel.gd new file mode 100644 index 0000000..1ad3c30 --- /dev/null +++ b/scripts/shortcut_wheel.gd @@ -0,0 +1,114 @@ +extends Node3D + +# One FTL-styled radial surface. Choosing a sector redraws once; no animated +# viewport or per-label meshes are needed while the wheel is held open. +const UI = preload("res://scripts/ftl_ui_theme.gd") +const SIZE := Vector2(0.54,0.60) +const PIXELS := Vector2(720,800) +var selected := -1 +var entries: Array = [] +var heading := "ACTIONS" +var viewport: SubViewport +var canvas: Control +var content_revision := 0 + + +func _ready() -> void: + viewport = SubViewport.new() + viewport.size = Vector2i(PIXELS) + viewport.transparent_bg = true + viewport.render_target_update_mode = SubViewport.UPDATE_ONCE + add_child(viewport) + canvas = WheelCanvas.new() + canvas.wheel = self + canvas.size = PIXELS + canvas.texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST + viewport.add_child(canvas) + var backing := MeshInstance3D.new() + var quad := QuadMesh.new() + quad.size = SIZE + backing.mesh = quad + backing.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF + var material := StandardMaterial3D.new() + material.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED + material.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA + material.no_depth_test = true + material.render_priority = 125 + material.texture_filter = BaseMaterial3D.TEXTURE_FILTER_NEAREST + material.albedo_texture = viewport.get_texture() + backing.material_override = material + add_child(backing) + visible = false + + +func set_entries(value: Array, title: String) -> void: + if entries == value and heading == title: + return + entries = value.duplicate(true) + heading = title + _redraw() + + +func choose(stick: Vector2) -> void: + # Top = 1, clockwise through 8. Center releases without an action. + var next := -1 if stick.length() < 0.45 else posmod(roundi(atan2(stick.x,stick.y) / (TAU / 8.0)),8) + if next == selected: + return + selected = next + _redraw() + + +func _redraw() -> void: + content_revision += 1 + if canvas != null: + canvas.queue_redraw() + viewport.render_target_update_mode = SubViewport.UPDATE_ONCE + + +class WheelCanvas extends Control: + var wheel: Node3D + const CENTER := Vector2(360,353) + + + func _sector(angle: float) -> PackedVector2Array: + var points := PackedVector2Array() + var half := TAU / 16.0 - .025 + for j in range(13): + var a := angle - half + (half * 2) * j / 12.0 + points.append(CENTER + Vector2(sin(a),-cos(a)) * 326) + for j in range(12,-1,-1): + var a := angle - half + (half * 2) * j / 12.0 + points.append(CENTER + Vector2(sin(a),-cos(a)) * 129) + return points + + + func _draw() -> void: + for i in range(8): + var enabled: bool = i < wheel.entries.size() and bool(wheel.entries[i].get("enabled",true)) + var active: bool = wheel.selected == i and enabled + var angle := i * TAU / 8.0 + var outline := _sector(angle) + draw_colored_polygon(outline,UI.SELECTED if active else UI.PANEL if enabled else UI.DARK) + outline.append(outline[0]) + draw_polyline(outline,UI.GOLD if active else UI.EDGE if enabled else UI.MUTED,1.5,false) + var center := CENTER + Vector2(sin(angle),-cos(angle)) * 225 + UI.centered_text(self,str(i + 1),center + Vector2(0,-22),38,UI.GOLD if active else UI.GREEN if enabled else UI.MUTED,"header") + var label := str(wheel.entries[i].get("label","")) if i < wheel.entries.size() else "-" + var lines := label.replace("\n"," ").split(" ",false) + var current := "" + var wrapped: Array[String] = [] + for word in lines: + if current.length() + word.length() + 1 > 12 and current != "": + wrapped.append(current) + current = "" + current = word if current == "" else current + " " + word + if current != "": wrapped.append(current) + for line in range(mini(3,wrapped.size())): + UI.centered_text(self,wrapped[line],center + Vector2(0,11 + line * 21),24,UI.INK if enabled else UI.MUTED) + UI.frame(self,Rect2(CENTER - Vector2(102,56),Vector2(204,112)),UI.DARK,UI.EDGE,17) + var titles := str(wheel.heading).split("\n") + for i in range(titles.size()): + UI.centered_text(self,titles[i],CENTER + Vector2(0,(i - (titles.size() - 1) * .5) * 29),25,UI.INK,"header") + UI.frame(self,Rect2(38,700,644,88),UI.DARK,UI.EDGE,12) + UI.centered_text(self,"STICK: CHOOSE RELEASE BUMPER: CONFIRM",Vector2(360,727),24,UI.INK) + UI.centered_text(self,"A: CATEGORY B / CENTER: CANCEL",Vector2(360,760),24,UI.MUTED) diff --git a/scripts/shortcut_wheel.gd.uid b/scripts/shortcut_wheel.gd.uid new file mode 100644 index 0000000..f79b03e --- /dev/null +++ b/scripts/shortcut_wheel.gd.uid @@ -0,0 +1 @@ +uid://bx7kp57ainba2 diff --git a/scripts/space_environment.gd b/scripts/space_environment.gd new file mode 100644 index 0000000..5371a9a --- /dev/null +++ b/scripts/space_environment.gd @@ -0,0 +1,337 @@ +extends Node3D + +# Stationary surroundings: three draw calls cover the entire sky, with a sparse +# nearer shell for stereo parallax. No captured 2D background is used here. +const STAR_COUNT := 1600 +const NEAR_STAR_COUNT := 96 +const GALAXY_COUNT := 420 +const VALID_HAZARDS := ["clear", "asteroid", "sun", "storm", "nebula", "pulsar"] + +var star_positions := PackedVector3Array() +var hazard := "clear" +var simulation_paused := false +var elapsed := 0.0 +var hazard_root: Node3D +var rocks: Array[Dictionary] = [] +var clouds: Array[Node3D] = [] +var body: Node3D +var storm_arcs: Array[MeshInstance3D] = [] +var built := false +var cloud_material: ShaderMaterial +var body_material: ShaderMaterial + +const CLOUD_SHADER := """ +shader_type spatial; +render_mode unshaded, blend_mix, depth_draw_never, cull_front; +uniform vec4 tint : source_color = vec4(0.3, 0.15, 0.45, 0.18); +uniform float clock = 0.0; +varying vec3 local_point; +float hash(vec3 p) { + p = fract(p * vec3(0.1031, 0.1030, 0.0973)); + p += dot(p, p.yxz + 33.33); + return fract((p.x + p.y) * p.z); +} +float noise(vec3 p) { + vec3 i = floor(p); vec3 f = fract(p); f = f * f * (3.0 - 2.0 * f); + return mix(mix(mix(hash(i), hash(i+vec3(1,0,0)), f.x), + mix(hash(i+vec3(0,1,0)), hash(i+vec3(1,1,0)), f.x), f.y), + mix(mix(hash(i+vec3(0,0,1)), hash(i+vec3(1,0,1)), f.x), + mix(hash(i+vec3(0,1,1)), hash(i+vec3(1,1,1)), f.x), f.y), f.z); +} +void vertex() { local_point = VERTEX; } +void fragment() { + vec3 p = local_point * 0.23 + vec3(clock * 0.012, 0.0, clock * 0.007); + float n = noise(p) * 0.58 + noise(p * 2.1) * 0.28 + noise(p * 4.3) * 0.14; + ALBEDO = tint.rgb * (0.5 + n * 0.5); + ALPHA = smoothstep(0.34, 0.7, n) * tint.a; +} +""" + +const STAR_BODY_SHADER := """ +shader_type spatial; +render_mode unshaded; +uniform vec4 tint : source_color = vec4(1.0, 0.22, 0.015, 1.0); +uniform float clock = 0.0; +varying vec3 local_point; +float hash(vec3 p) { + p = fract(p * vec3(0.1031, 0.1030, 0.0973)); + p += dot(p, p.yxz + 33.33); + return fract((p.x + p.y) * p.z); +} +float noise(vec3 p) { + vec3 i = floor(p); vec3 f = fract(p); f = f * f * (3.0 - 2.0 * f); + return mix(mix(mix(hash(i), hash(i+vec3(1,0,0)), f.x), + mix(hash(i+vec3(0,1,0)), hash(i+vec3(1,1,0)), f.x), f.y), + mix(mix(hash(i+vec3(0,0,1)), hash(i+vec3(1,0,1)), f.x), + mix(hash(i+vec3(0,1,1)), hash(i+vec3(1,1,1)), f.x), f.y), f.z); +} +void vertex() { local_point = VERTEX; } +void fragment() { + vec3 p = normalize(local_point); + vec3 drift = vec3(clock * 0.035, clock * 0.012, 0.0); + float cells = smoothstep(0.18, 0.78, noise(p * 23.0 + drift) * 0.65 + noise(p * 47.0) * 0.35); + ALBEDO = mix(tint.rgb * 0.34, tint.rgb, cells); + EMISSION = mix(tint.rgb, vec3(1.0, 0.88, 0.54), cells * 0.35) * 1.8; +} +""" + +const CORONA_SHADER := """ +shader_type spatial; +render_mode unshaded, blend_add, depth_draw_never, cull_back; +uniform vec4 tint : source_color = vec4(1.0, 0.3, 0.035, 0.42); +void fragment() { + float rim = pow(max(dot(normalize(NORMAL), normalize(VIEW)), 0.0), 2.5); + ALBEDO = tint.rgb; + ALPHA = rim * tint.a; +} +""" + + +func build() -> void: + if built: + return + built = true + name = "SpaceEnvironment" + var rng := RandomNumberGenerator.new() + rng.seed = 26011002 + _star_shell("DistantStars360", STAR_COUNT, 30.0, 58.0, rng, false) + _star_shell("NearStarsParallax", NEAR_STAR_COUNT, 8.0, 17.0, rng, false) + _star_shell("DistantGalaxyBand", GALAXY_COUNT, 35.0, 54.0, rng, true) + hazard_root = Node3D.new() + hazard_root.name = "NativeHazard" + add_child(hazard_root) + _set_hazard("clear") + + +func apply_state(state: Dictionary) -> void: + if not built: + build() + simulation_paused = bool(state.get("paused", false)) + var next := str(state.get("hazard", "clear")) + if next in VALID_HAZARDS and next != hazard: + _set_hazard(next) + + +func set_hazard(kind: String) -> void: + if not built: + build() + if kind in VALID_HAZARDS and kind != hazard: + _set_hazard(kind) + + +func _star_shell(node_name: String, count: int, near_radius: float, far_radius: float, + rng: RandomNumberGenerator, galaxy: bool) -> void: + var sphere := SphereMesh.new() + sphere.radius = 1.0 + sphere.height = 2.0 + sphere.radial_segments = 6 + sphere.rings = 3 + var stars := MultiMesh.new() + stars.transform_format = MultiMesh.TRANSFORM_3D + stars.use_colors = true + stars.mesh = sphere + stars.instance_count = count + stars.custom_aabb = AABB(Vector3.ONE * -far_radius, Vector3.ONE * far_radius * 2.0) + for i in range(count): + var y := rng.randf_range(-1.0, 1.0) + var angle := rng.randf_range(0.0, TAU) + if galaxy: + y = rng.randfn(0.0, 0.1) + var radial := sqrt(maxf(0.0, 1.0 - y * y)) + var direction := Vector3(radial * cos(angle), y, radial * sin(angle)).normalized() + if galaxy: + direction = direction.rotated(Vector3.FORWARD, 0.42) + var point := direction * rng.randf_range(near_radius, far_radius) + var size := rng.randf_range(0.019, 0.048) * (near_radius / 30.0) + if galaxy: + size *= 0.8 + stars.set_instance_transform(i, Transform3D(Basis.IDENTITY.scaled(Vector3.ONE * size), point)) + var tint := Color(0.7, 0.81, 1.0).lerp(Color(1.0, 0.83, 0.65), rng.randf()) + tint *= rng.randf_range(0.42, 1.0) if not galaxy else rng.randf_range(0.12, 0.28) + tint.a = 1.0 + stars.set_instance_color(i, tint) + if not galaxy: + star_positions.append(point) + var node := MultiMeshInstance3D.new() + node.name = node_name + node.multimesh = stars + node.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF + var material := StandardMaterial3D.new() + material.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED + material.vertex_color_use_as_albedo = true + material.albedo_color = Color.WHITE + node.material_override = material + add_child(node) + + +func _set_hazard(kind: String) -> void: + hazard = kind + for child in hazard_root.get_children(): + hazard_root.remove_child(child) + child.queue_free() + rocks.clear() + clouds.clear() + storm_arcs.clear() + body = null + cloud_material = null + body_material = null + elapsed = 0.0 + match kind: + "asteroid": + _build_asteroids() + "sun", "pulsar": + _build_star_body(kind) + "storm", "nebula": + _build_nebula(kind) + + +func _build_asteroids() -> void: + var rng := RandomNumberGenerator.new() + rng.seed = 7262026 + var mesh := SphereMesh.new() + mesh.radius = 1.0 + mesh.height = 2.0 + mesh.radial_segments = 7 + mesh.rings = 4 + var material := StandardMaterial3D.new() + material.albedo_color = Color(0.31, 0.29, 0.28) + material.roughness = 1.0 + for i in range(32): + var rock := MeshInstance3D.new() + rock.mesh = mesh + rock.material_override = material + rock.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF + var angle := rng.randf_range(0.0, TAU) + var radius := rng.randf_range(3.0, 9.0) + rock.position = Vector3(cos(angle) * radius, rng.randf_range(-1.7, 5.0), sin(angle) * radius) + var size := rng.randf_range(0.12, 0.38) + rock.scale = Vector3(rng.randf_range(0.7, 1.2), rng.randf_range(0.65, 1.1), rng.randf_range(0.6, 1.2)) * size + rock.rotation = Vector3(rng.randf(), rng.randf(), rng.randf()) * TAU + hazard_root.add_child(rock) + rocks.append({"node": rock, "start": rock.position, + "velocity": Vector3(0.055, 0.007, -0.022) * rng.randf_range(0.5, 1.4), + "spin": Vector3(rng.randf_range(-0.2, 0.2), rng.randf_range(-0.2, 0.2), rng.randf_range(-0.2, 0.2))}) + + +func _build_star_body(kind: String) -> void: + body = Node3D.new() + body.name = "Sun" if kind == "sun" else "Pulsar" + body.position = Vector3(-12.0, 8.0, -19.0) + body.rotation = Vector3(0.28, 0.0, -0.45) + hazard_root.add_child(body) + var color := Color(1.0, 0.23, 0.025) if kind == "sun" else Color(0.26, 0.67, 1.0) + var radius := 4.0 if kind == "sun" else 1.9 + var surface := MeshInstance3D.new() + var sphere := SphereMesh.new() + sphere.radius = radius + sphere.height = radius * 2.0 + sphere.radial_segments = 32 + sphere.rings = 16 + surface.mesh = sphere + body_material = ShaderMaterial.new() + body_material.shader = Shader.new() + body_material.shader.code = STAR_BODY_SHADER + body_material.set_shader_parameter("tint", color) + surface.material_override = body_material + surface.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF + body.add_child(surface) + for scale_factor in [1.08, 1.23]: + var corona := MeshInstance3D.new() + corona.mesh = sphere + corona.scale = Vector3.ONE * scale_factor + var material := ShaderMaterial.new() + material.shader = Shader.new() + material.shader.code = CORONA_SHADER + material.set_shader_parameter("tint", Color(color, 0.32)) + corona.material_override = material + corona.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF + body.add_child(corona) + if kind == "pulsar": + for sign_value in [-1.0, 1.0]: + var jet := MeshInstance3D.new() + var cone := CylinderMesh.new() + cone.top_radius = 1.25 if sign_value > 0.0 else 0.06 + cone.bottom_radius = 0.06 if sign_value > 0.0 else 1.25 + cone.height = 11.0 + cone.radial_segments = 12 + jet.mesh = cone + jet.position.y = sign_value * 6.5 + var material := StandardMaterial3D.new() + material.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED + material.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA + material.blend_mode = BaseMaterial3D.BLEND_MODE_ADD + material.albedo_color = Color(0.1, 0.42, 0.85, 0.09) + jet.material_override = material + jet.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF + body.add_child(jet) + + +func _build_nebula(kind: String) -> void: + var rng := RandomNumberGenerator.new() + rng.seed = 729 if kind == "storm" else 820 + cloud_material = ShaderMaterial.new() + cloud_material.shader = Shader.new() + cloud_material.shader.code = CLOUD_SHADER + cloud_material.set_shader_parameter("tint", Color(0.12, 0.42, 0.7, 0.24) if kind == "storm" else Color(0.5, 0.19, 0.67, 0.23)) + # Concentric interior shells avoid the visible triangle intersections caused + # by overlapping transparent cloud spheres. Their distinct depths/rotations + # still give stereo parallax while the nebula surrounds the player fully. + for i in range(3): + var cloud := MeshInstance3D.new() + var sphere := SphereMesh.new() + sphere.radius = 16.0 + float(i) * 9.0 + sphere.height = sphere.radius * 2.0 + sphere.radial_segments = 64 + sphere.rings = 32 + cloud.mesh = sphere + cloud.position.y = 1.6 + cloud.rotation = Vector3(rng.randf(), rng.randf(), rng.randf()) * TAU + cloud.material_override = cloud_material + cloud.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF + hazard_root.add_child(cloud) + clouds.append(cloud) + if kind == "storm": + # Faint distant electrical filaments are atmosphere only; native FTL still + # decides every ion hit and hazard effect. + for i in range(5): + var arc := MeshInstance3D.new() + var mesh := ImmediateMesh.new() + mesh.surface_begin(Mesh.PRIMITIVE_LINES) + var angle := float(i) / 5.0 * TAU + var point := Vector3(cos(angle) * 10.0, rng.randf_range(0.5, 6.0), sin(angle) * 10.0) + for part in range(5): + var next := point + Vector3(rng.randf_range(-0.8, 0.8), rng.randf_range(-0.7, 0.7), rng.randf_range(-0.7, 0.7)) + mesh.surface_add_vertex(point) + mesh.surface_add_vertex(next) + point = next + mesh.surface_end() + arc.mesh = mesh + var material := StandardMaterial3D.new() + material.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED + material.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA + material.blend_mode = BaseMaterial3D.BLEND_MODE_ADD + material.albedo_color = Color(0.28, 0.58, 0.82, 0.25) + arc.material_override = material + arc.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF + hazard_root.add_child(arc) + storm_arcs.append(arc) + + +func _process(delta: float) -> void: + if simulation_paused or not built: + return + elapsed += delta + for rock in rocks: + var node: Node3D = rock["node"] + node.position += Vector3(rock["velocity"]) * delta + node.rotation += Vector3(rock["spin"]) * delta + if node.position.distance_to(Vector3.ZERO) > 11.0: + node.position = rock["start"] + if cloud_material != null: + cloud_material.set_shader_parameter("clock", elapsed) + if body_material != null: + body_material.set_shader_parameter("clock", elapsed) + if is_instance_valid(body): + body.rotation.y += delta * 0.005 + for i in range(storm_arcs.size()): + storm_arcs[i].visible = sin(elapsed * 0.43 + i * 1.73) > 0.88 diff --git a/scripts/space_environment.gd.uid b/scripts/space_environment.gd.uid new file mode 100644 index 0000000..c9649db --- /dev/null +++ b/scripts/space_environment.gd.uid @@ -0,0 +1 @@ +uid://d2rmdd3mkhfap diff --git a/scripts/voxel_crew.gd b/scripts/voxel_crew.gd new file mode 100644 index 0000000..d524ae2 --- /dev/null +++ b/scripts/voxel_crew.gd @@ -0,0 +1,190 @@ +extends Node3D + +const Voxels = preload("res://scripts/voxel_mesh.gd") +const UNIT := 0.012 +const SPECIES := ["human", "engi", "mantis", "rock", "slug", "energy", "crystal", "lanius"] +var species := "human" +var animation_state := "idle" +var animation_time := 0.0 +var torso: Node3D +var head: Node3D +var arms: Array[Node3D] = [] +var legs: Array[Node3D] = [] +var tool: Node3D +var facing := 0.0 +var viewer_facing_valid := false + + +func build(race: String) -> void: + for child in get_children(): + remove_child(child) + child.queue_free() + arms.clear() + legs.clear() + species = canonical_species(race) + if not SPECIES.has(species): + species = "human" + torso = Node3D.new() + torso.name = "Torso" + add_child(torso) + head = Node3D.new() + head.name = "Head" + head.position = Vector3(0, 8.4, 0) * UNIT + torso.add_child(head) + var dark := Color("253744") + var light := Color("b9c8cc") + var eye := Color("a4f4ff") + var body_blocks: Array = [] + var head_blocks: Array = [] + var arm_blocks: Array = [] + var leg_blocks: Array = [] + match species: + "human": + body_blocks = [_b(0, 5.4, 0, 4.2, 4.8, 2.6, Color("467da6")), _b(0, 3.3, 0, 4.3, 0.7, 2.8, dark), _b(0, 6.3, -1.5, 1.4, 1.4, 0.5, Color("ffd777"))] + head_blocks = [_b(0, 0.8, 0, 3.2, 3.1, 3.0, Color("c89571")), _b(0, 2.2, 0.2, 3.4, 0.8, 3.2, Color("5a392c")), _b(-0.7, 0.9, -1.55, 0.55, 0.6, 0.2, dark), _b(0.7, 0.9, -1.55, 0.55, 0.6, 0.2, dark)] + arm_blocks = [_b(0, -1.5, 0, 1.4, 3.3, 1.7, Color("467da6")), _b(0, -3.25, -0.2, 1.3, 1.0, 1.4, Color("c89571"))] + leg_blocks = [_b(0, -1.5, 0, 1.6, 3.2, 1.8, dark), _b(0, -3.2, -0.4, 1.7, 0.9, 2.5, Color("16232e"))] + "engi": + body_blocks = [_b(0, 5.0, 0, 4.6, 3.8, 3.2, light), _b(0, 5.2, -1.8, 2.1, 2.2, 0.6, dark), _b(0, 5.2, -2.15, 1.2, 0.6, 0.2, eye), _b(0, 7.1, 0, 2.0, 1.0, 2.0, dark)] + head_blocks = [_b(0, 0.5, 0, 3.4, 2.6, 3.0, light), _b(0, 0.7, -1.6, 2.5, 1.1, 0.4, dark), _b(-0.65, 0.7, -1.85, 0.6, 0.6, 0.15, eye), _b(0.65, 0.7, -1.85, 0.6, 0.6, 0.15, eye)] + arm_blocks = [_b(0, -0.8, 0, 1.6, 1.6, 1.7, light), _b(0, -2, 0, 0.7, 1.1, 0.9, dark), _b(0, -3, -0.2, 1.8, 1.4, 1.7, light)] + leg_blocks = [_b(0, -0.7, 0, 1.4, 1.5, 1.8, light), _b(0, -1.9, 0, 0.7, 1.0, 1.0, dark), _b(0, -3, -0.5, 1.9, 1.4, 2.7, light)] + "mantis": + body_blocks = [_b(0, 5.2, 0, 2.7, 4.5, 2.4, Color("568e3b")), _b(0, 4.1, 1.2, 3.5, 2.3, 3.0, Color("759f45")), _b(0, 7, 0, 2.0, 1.4, 2.1, Color("375d29"))] + head_blocks = [_b(0, 0.5, -0.5, 3.7, 2.8, 2.7, Color("8fb654")), _b(-1.5, 0.8, -2.0, 0.9, 1.2, 0.7, Color("df5146")), _b(1.5, 0.8, -2.0, 0.9, 1.2, 0.7, Color("df5146")), _b(-1.2, 2.9, 0, 0.35, 2.2, 0.4, dark), _b(1.2, 2.9, 0, 0.35, 2.2, 0.4, dark), _b(-0.9, -1.0, -2.0, 0.5, 1.2, 1.2, Color("e7d09a")), _b(0.9, -1.0, -2.0, 0.5, 1.2, 1.2, Color("e7d09a"))] + arm_blocks = [_b(0, -1.3, 0, 0.8, 3.3, 1.0, Color("568e3b")), _b(0, -3.2, -0.7, 0.9, 2.3, 1.8, Color("bdd887")), _b(0, -4.5, -1.4, 0.7, 1.2, 0.7, Color("e7d09a"))] + leg_blocks = [_b(0, -1.4, 0.5, 0.9, 2.8, 1.1, Color("568e3b")), _b(0, -3.0, -0.7, 0.8, 1.4, 2.3, Color("759f45"))] + "rock": + body_blocks = [_b(0, 5.1, 0, 6.4, 4.8, 4.3, Color("8c7252")), _b(-2.4, 6.3, 0, 2.5, 3.1, 3.4, Color("ac9065")), _b(2.4, 5.8, 0, 2.3, 3.3, 3.8, Color("74624b")), _b(0.7, 3.4, -2.3, 2.3, 1.9, 0.7, Color("6a563f"))] + head_blocks = [_b(0, 0.4, 0, 4.6, 3.5, 3.7, Color("ac9065")), _b(-1.8, 1.4, 0.2, 1.2, 2.5, 2.7, Color("8c7252")), _b(0, 0.3, -2.0, 3.1, 0.7, 0.4, dark), _b(-0.8, 0.3, -2.25, 0.7, 0.35, 0.2, Color("ffe4a0")), _b(0.8, 0.3, -2.25, 0.7, 0.35, 0.2, Color("ffe4a0"))] + arm_blocks = [_b(0, -1.3, 0, 2.3, 3.7, 3.1, Color("8c7252")), _b(0, -3.2, -0.3, 2.6, 1.9, 3.0, Color("ac9065"))] + leg_blocks = [_b(0, -1.6, 0, 2.3, 3.3, 2.7, Color("74624b")), _b(0, -3.1, -0.5, 2.6, 1.1, 3.4, Color("8c7252"))] + "slug": + body_blocks = [_b(0, 3.2, 0.9, 5.4, 3.0, 6.5, Color("846aa8")), _b(0, 5.0, 0, 4.6, 3.8, 4.0, Color("a887c1")), _b(0, 2.0, 3.4, 3.3, 1.7, 3.6, Color("725793")), _b(0, 1.25, 1.0, 5.8, 0.7, 8.2, Color("58446e"))] + head_blocks = [_b(0, -0.5, 0, 4.5, 2.6, 3.8, Color("a887c1")), _b(-1.5, 1.6, -1.1, 0.7, 3.5, 0.7, Color("a887c1")), _b(1.5, 1.6, -1.1, 0.7, 3.5, 0.7, Color("a887c1")), _b(-1.5, 3.2, -1.2, 1.3, 1.0, 1.1, Color("e1e791")), _b(1.5, 3.2, -1.2, 1.3, 1.0, 1.1, Color("e1e791")), _b(-1.5, 3.2, -1.8, 0.5, 0.7, 0.2, dark), _b(1.5, 3.2, -1.8, 0.5, 0.7, 0.2, dark)] + arm_blocks = [_b(0, -1.5, -0.3, 1.0, 3.1, 1.4, Color("a887c1")), _b(0, -2.8, -1.1, 1.5, 1.0, 1.5, Color("846aa8"))] + "energy": + body_blocks = [_b(0, 5.2, 0, 3.5, 4.7, 2.4, Color("57c282")), _b(0, 5.5, -1.35, 1.0, 3.1, 0.4, Color("b4ffb0")), _b(-1.9, 6.4, 0, 0.6, 2.0, 2.6, Color("91e999")), _b(1.9, 6.4, 0, 0.6, 2.0, 2.6, Color("91e999"))] + head_blocks = [_b(0, 0.8, 0, 2.7, 3.5, 2.5, Color("8be891")), _b(0, 2.8, 0, 1.8, 1.0, 1.7, Color("b4ffb0")), _b(0, 0.9, -1.4, 1.7, 0.6, 0.3, Color("e6ffd1"))] + arm_blocks = [_b(0, -1.7, 0, 1.0, 3.7, 1.3, Color("8be891")), _b(0, -3.7, 0, 1.1, 0.6, 1.4, Color("b4ffb0"))] + leg_blocks = [_b(0, -1.7, 0, 1.1, 3.5, 1.6, Color("57c282")), _b(0, -3.4, -0.3, 1.3, 0.8, 2.1, Color("b4ffb0"))] + "crystal": + body_blocks = [_b(0, 5.1, 0, 4.7, 5.0, 3.5, Color("65a6bf")), _b(-1.9, 6.2, 0, 1.7, 4.4, 2.7, Color("b3e3ed")), _b(1.9, 5.4, -0.2, 1.7, 3.2, 3.2, Color("85c4d7")), _b(0, 5.4, -2.0, 1.6, 2.5, 0.7, Color("c9f5f8"))] + head_blocks = [_b(0, 0.6, 0, 3.5, 3.4, 3.1, Color("85c4d7")), _b(-0.9, 2.8, 0.2, 1.3, 2.0, 1.9, Color("c9f5f8")), _b(1.0, 2.1, 0.4, 1.4, 1.2, 2.2, Color("b3e3ed")), _b(0, 0.8, -1.7, 2.2, 0.6, 0.3, dark)] + arm_blocks = [_b(0, -1.6, 0, 1.8, 3.8, 2.3, Color("85c4d7")), _b(0, -3.6, -0.2, 1.9, 1.4, 2.4, Color("b3e3ed"))] + leg_blocks = [_b(0, -1.8, 0, 1.8, 3.7, 2.3, Color("65a6bf")), _b(0, -3.5, -0.5, 2.0, 1.0, 2.8, Color("85c4d7"))] + "lanius": + body_blocks = [_b(0, 5.2, 0, 3.4, 4.5, 2.3, Color("9eaaaf")), _b(-1.6, 6.6, 0, 1.3, 2.0, 2.4, Color("d0d8dc")), _b(1.6, 6.6, 0, 1.3, 2.0, 2.4, Color("d0d8dc")), _b(0, 5.0, -1.5, 0.7, 2.7, 0.8, Color("667f8c"))] + head_blocks = [_b(0, 0.9, 0, 2.7, 3.2, 2.7, Color("d0d8dc")), _b(-1.25, 2.8, 0.3, 0.6, 2.5, 0.7, Color("9eaaaf")), _b(1.25, 2.8, 0.3, 0.6, 2.5, 0.7, Color("9eaaaf")), _b(0, 0.5, -1.6, 1.9, 0.6, 0.4, Color("e7f788"))] + arm_blocks = [_b(0, -1.5, 0, 1.0, 3.5, 1.4, Color("9eaaaf")), _b(0, -3.4, -0.6, 0.6, 2.2, 1.5, Color("d0d8dc"))] + leg_blocks = [_b(0, -1.7, 0, 1.0, 3.7, 1.4, Color("9eaaaf")), _b(0, -3.5, -0.7, 0.9, 1.2, 2.9, Color("d0d8dc"))] + torso.add_child(Voxels.make(body_blocks)) + head.add_child(Voxels.make(head_blocks)) + for side in [-1.0, 1.0]: + var arm := Node3D.new() + arm.position = Vector3(side * (3.4 if species == "rock" else 2.5), 7.0, 0) * UNIT + arm.add_child(Voxels.make(arm_blocks)) + torso.add_child(arm) + arms.append(arm) + if species != "slug": + var leg := Node3D.new() + leg.position = Vector3(side * (1.6 if species == "rock" else 1.0), 3.6, 0) * UNIT + leg.add_child(Voxels.make(leg_blocks)) + torso.add_child(leg) + legs.append(leg) + tool = Voxels.make([_b(0, 0, 0, 0.8, 3.5, 0.8, light), _b(0, -1.5, 0, 2.2, 0.8, 1.1, Color("ffc967"))]) + tool.position = Vector3(0, -3.6, -0.8) * UNIT + arms[1].add_child(tool) + tool.visible = false + animate(0.0) + + +func set_state(crew: Dictionary) -> void: + var previous_state := animation_state + if bool(crew.get("fighting", false)): + animation_state = "fight" + elif bool(crew.get("repairing", false)): + animation_state = "repair" + elif bool(crew.get("running", false)): + animation_state = "run" + elif bool(crew.get("working", false)): + animation_state = "work" + else: + animation_state = "idle" + tool.visible = animation_state == "repair" + if animation_state != previous_state: + # Native state changes must select the right pose even when an actor is + # first seen while paused. Preserve the frozen animation phase. + animate(0.0) + + +func face_viewer(deck_direction: Vector3, delta: float) -> void: + # Rotate around the deck normal only. Looking straight down at an actor + # must not tip its body or cause unstable yaw from a near-zero projection. + if animation_state == "run" or Vector2(deck_direction.x, deck_direction.z).length_squared() < 0.0004: + return + var target := atan2(-deck_direction.x, -deck_direction.z) + var difference := wrapf(target - facing, -PI, PI) + if not viewer_facing_valid: + facing = target + viewer_facing_valid = true + elif absf(difference) > 0.012: + facing = lerp_angle(facing, target, 1.0 - exp(-delta * 12.0)) + rotation.y = facing + + +func face_movement(deck_direction: Vector3) -> void: + if animation_state != "run" or deck_direction.length_squared() < 0.00004: + return + facing = atan2(-deck_direction.x, -deck_direction.z) + rotation.y = facing + viewer_facing_valid = false + + +func animate(delta: float) -> void: + animation_time += delta + var t := animation_time + for part in arms + legs: + part.rotation = Vector3.ZERO + torso.position = Vector3.ZERO + torso.rotation = Vector3.ZERO + head.rotation = Vector3.ZERO + match animation_state: + "run": + for i in range(legs.size()): + legs[i].rotation.x = sin(t * 14.0 + i * PI) * 0.65 + for i in range(arms.size()): + arms[i].rotation.x = sin(t * 14.0 + i * PI + PI) * 0.5 + torso.position.y = absf(sin(t * 14.0)) * 0.004 + torso.rotation.x = -0.12 + if species == "slug": + torso.scale.z = 1.0 + sin(t * 9.0) * 0.08 + "work": + arms[0].rotation.x = 0.9 + sin(t * 5.0) * 0.14 + arms[1].rotation.x = 0.9 + sin(t * 5.0 + PI) * 0.14 + head.rotation.x = -0.12 + "repair": + arms[0].rotation.x = 0.8 + arms[1].rotation.x = 1.0 + sin(t * 11.0) * 0.45 + torso.rotation.x = -0.2 + torso.position.y = -0.005 + head.rotation.x = -0.18 + "fight": + arms[0].rotation.x = 0.9 + sin(t * 9.0) * 0.65 + arms[1].rotation.x = 0.9 + sin(t * 9.0 + PI) * 0.65 + torso.rotation.y = sin(t * 9.0) * 0.12 + torso.position.z = sin(t * 9.0) * 0.004 + _: + head.rotation.y = sin(t * 1.4) * 0.06 + torso.position.y = sin(t * 2.0) * 0.001 + if animation_state != "run": + torso.scale.z = 1.0 + rotation.y = facing + + +func _b(x: float, y: float, z: float, w: float, h: float, d: float, color: Color) -> Array: + return Voxels.block(Vector3(x, y, z) * UNIT, Vector3(w, h, d) * UNIT, color) + + +static func canonical_species(race: String) -> String: + return {"zoltan": "energy", "anaerobic": "lanius"}.get(race.to_lower(), race.to_lower()) diff --git a/scripts/voxel_crew.gd.uid b/scripts/voxel_crew.gd.uid new file mode 100644 index 0000000..b3fd539 --- /dev/null +++ b/scripts/voxel_crew.gd.uid @@ -0,0 +1 @@ +uid://bbm2s2addmmih diff --git a/scripts/voxel_drone.gd b/scripts/voxel_drone.gd new file mode 100644 index 0000000..0f2bdc1 --- /dev/null +++ b/scripts/voxel_drone.gd @@ -0,0 +1,247 @@ +extends Node3D + +const Voxels = preload("res://scripts/voxel_mesh.gd") +const UNIT := 0.01 +const STEEL := Color("a5b8be") +const RIM := Color("d3dcda") +const DARK := Color("354b59") +const BLACK := Color("182832") +const BRASS := Color("c6ac75") +static var body_meshes: Dictionary = {} +static var body_material: StandardMaterial3D +var drone_name := "" +var kind := "combat" +var family := "combat" +var is_space := true +var body: MeshInstance3D +var thruster: MeshInstance3D +var emitter: MeshInstance3D +var tool: MeshInstance3D +var live_data: Dictionary = {} +var animation_time := 0.0 +var firing := false + + +static func model_family(data: Dictionary) -> String: + # Species and blueprint names are native identifiers, independent of the + # player's language. Crew drone names can be translated or renamed. + var identity := (str(data.get("kind", "")) + " " + str(data.get("species", "")) + " " + str(data.get("name", ""))).to_lower() + if not bool(data.get("is_space", true)): + match str(data.get("species", "")).to_lower(): + "repair": return "repair" + "battle", "boarder": return "battle" + "ion": return "ion_boarder" + if identity.contains("ion"): return "ion_boarder" + if identity.contains("battle") or identity.contains("board") or identity.contains("anti_person"): return "battle" + return "repair" + if identity.contains("hack"): return "hacking" + if identity.contains("anti") and identity.contains("drone"): return "anti_drone" + if identity.contains("shield"): return "shield" + if identity.contains("board") or identity.contains("ion"): return "boarding" + if identity.contains("defense") or identity.contains("defence"): return "defense" + if identity.contains("beam"): return "beam" + return "combat" + + +func build(data: Dictionary) -> void: + for child in get_children(): + remove_child(child) + child.queue_free() + drone_name = str(data.get("name", "")) + kind = str(data.get("kind", "combat")).to_lower() + is_space = bool(data.get("is_space", true)) + family = model_family(data) + animation_time = 0.0 + var mark_two := drone_name.ends_with("_2") and family in ["combat", "defense"] + var cache_key := family + ("_2" if mark_two else "") + if not body_meshes.has(cache_key): + var source := Voxels.make(_body_blocks(mark_two)) + body_meshes[cache_key] = source.mesh + if body_material == null: + body_material = source.material_override + source.free() + body = MeshInstance3D.new() + body.name = "Body" + body.mesh = body_meshes[cache_key] + body.material_override = body_material + add_child(body) + var exhaust: Array = [] + var lights: Array = [] + var glow := Color("69d8fa") + if is_space: + if family == "boarding": + exhaust = [_b(-8.0, 0, -2.5, 2.8, 1.5, 1.5, glow), _b(-8.0, 0, 2.5, 2.8, 1.5, 1.5, glow)] + elif family == "shield": + exhaust = [_b(-6.6, -1.3, 0, 2.0, 1.0, 2.4, glow)] + elif family == "hacking": + exhaust = [_b(-5.4, 0, 0, 2.0, 1.2, 2.0, glow)] + else: + for side in [-1.0, 1.0]: + exhaust.append(_b(-8.4, -0.5, side * 5.4, 2.4, 1.2, 1.8, glow)) + match family: + "combat": + glow = Color("f2725b") + for z in ([-1.8, 1.8] if mark_two else [0.0]): lights.append(_b(13.2, 0.3, z, 0.6, 1.3, 1.3, glow)) + "beam": + glow = Color("ffdc76") + lights = [_b(13.0, 0, 0, 0.6, 1.0, 3.7, glow)] + "defense", "anti_drone": + glow = Color("98e5aa") + for z in ([-1.5, 1.5] if mark_two else [0.0]): lights.append(_b(10.7, 2.4, z, 0.6, 1.3, 1.3, glow)) + "hacking": + glow = Color("b591e0") + lights = [_b(0, 2.6, 0, 2.6, 0.4, 2.6, glow)] + "shield": + for side in [-1.0, 1.0]: + lights.append(_b(0, 1.4, side * 6.2, 4.2, 0.4, 2.0, glow)) + lights.append(_b(side * 6.2, 1.4, 0, 2.0, 0.4, 4.2, glow)) + "boarding": lights = [_b(8.3, 0, 0, 0.5, 1.5, 2.5, Color("e69468"))] + else: + glow = Color("ef736a") if family == "battle" else Color("7fdafa") + lights = [_b(0, 9.0, -3.0, 2.8, 0.7, 0.3, glow)] + thruster = _glow_mesh(exhaust, Color("69d8fa")) + thruster.name = "Exhaust" + add_child(thruster) + emitter = _glow_mesh(lights, glow) + emitter.name = "Emitter" + add_child(emitter) + tool = null + if not is_space: + var tool_blocks: Array + if family == "repair": + tool_blocks = [_b(0, -0.5, 0, 0.9, 3.0, 1.0, STEEL), _b(0, -2.1, -0.7, 2.4, 0.7, 1.3, BRASS), + _b(-0.9, -2.6, -0.8, 0.5, 1.1, 1.3, RIM), _b(0.9, -2.6, -0.8, 0.5, 1.1, 1.3, RIM)] + else: + tool_blocks = [_b(0, -1.2, 0, 1.8, 3.0, 2.0, DARK), _b(0, -2.4, -1.0, 2.7, 1.0, 3.0, STEEL), + _b(-1.0, -2.9, -2.0, 0.6, 1.1, 1.7, RIM), _b(1.0, -2.9, -2.0, 0.6, 1.1, 1.7, RIM)] + tool = Voxels.make(tool_blocks) + tool.name = "Tool" + tool.position = Vector3(5.2, 6.3, 0) * UNIT + add_child(tool) + set_live(data) + + +func _body_blocks(mark_two: bool) -> Array: + var blocks: Array = [] + if not is_space: + # Repair: wheeled service robot and wrench. Battle: an armored torso, + # articulated claws and feet. Ion boarder: conductive antenna vanes. + var armored := family != "repair" + blocks = [_b(0, 5.0, 0, 6.4 if armored else 5.5, 5.0, 4.8, STEEL), + _b(0, 3.0, 0, 6.8 if armored else 5.8, 1.0, 5.0, DARK), + _b(0, 8.8, 0, 4.7, 2.5, 5.2, DARK), _b(0, 10.0, 0.5, 5.2, 0.6, 4.2, STEEL), + _b(0, 9.0, -2.8, 3.5, 1.5, 0.4, BLACK), _b(0, 5.2, -2.6, 2.8, 2.0, 0.5, DARK)] + for side in [-1.0, 1.0]: + blocks.append(_b(side * 4.1, 6.6, 0, 2.0, 2.0, 3.0, BRASS if not armored else STEEL)) + blocks.append(_b(side * 5.2, 5.2, 0, 0.9, 2.0, 1.2, DARK)) + if armored: + blocks.append(_b(side * 2.0, 1.8, 0, 1.8, 2.5, 2.0, DARK)) + blocks.append(_b(side * 2.0, 0.6, -0.8, 2.4, 1.2, 4.1, STEEL)) + blocks.append(_b(side * 4.3, 6.6, -0.5, 2.7, 1.2, 3.8, RIM)) + else: + blocks.append(_b(side * 2.7, 1.0, 0, 1.8, 2.0, 5.5, DARK)) + for z in [-1.8, 0.0, 1.8]: blocks.append(_b(side * 3.7, 1.0, z, 0.3, 1.3, 0.7, RIM)) + blocks.append(_b(-5.1, 3.7, -0.7, 1.4, 1.2, 2.8, STEEL)) + if family == "ion_boarder": + for side in [-1.0, 1.0]: + blocks.append(_b(side * 2.0, 11.2, 0, 0.8, 2.0, 2.3, Color("6aa8c5"))) + blocks.append(_b(side * 3.2, 5.4, -2.9, 0.6, 3.8, 0.8, Color("6aa8c5"))) + return blocks + match family: + "hacking": + blocks = [_b(0, 0, 0, 8.5, 3.7, 8.5, DARK), _b(0, 1.7, 0, 5.8, 1.5, 5.8, STEEL), + _b(0, 2.5, 0, 3.5, 0.6, 3.5, BLACK), _b(-4.5, 0, 0, 2.2, 2.8, 2.8, STEEL)] + for side in [-1.0, 1.0]: + for end in [-1.0, 1.0]: + blocks.append(_b(side * 4.8, -0.5, end * 4.2, 2.0, 1.3, 3.2, STEEL)) + blocks.append(_b(side * 5.2, -2.5, end * 5.4, 1.1, 3.2, 1.1, BRASS)) + blocks.append(_b(side * 4.7, -4.0, end * 5.4, 2.1, 0.7, 1.3, DARK)) + "shield": + blocks = [_b(0, -0.6, 0, 6.0, 3.0, 6.0, DARK), _b(0, 1.0, 0, 3.0, 1.0, 3.0, STEEL)] + for side in [-1.0, 1.0]: + blocks.append(_b(0, 0, side * 6.2, 7.2, 2.0, 4.2, STEEL)) + blocks.append(_b(side * 6.2, 0, 0, 4.2, 2.0, 7.2, STEEL)) + blocks.append(_b(0, 1.1, side * 6.2, 5.0, 0.5, 2.8, DARK)) + blocks.append(_b(side * 6.2, 1.1, 0, 2.8, 0.5, 5.0, DARK)) + blocks.append(_b(side * 4.7, -1.0, side * 4.7, 2.8, 0.8, 2.8, BRASS)) + "boarding": + blocks = [_b(-1, 0, 0, 13.0, 5.0, 7.0, STEEL), _b(4.7, 0, 0, 5.0, 4.0, 6.0, DARK), + _b(7.7, 0, 0, 1.2, 3.0, 4.0, BRASS), _b(-6.0, 0, 0, 2.0, 6.0, 8.5, DARK), + _b(-1, 2.7, 0, 7.0, 0.7, 4.0, RIM)] + for side in [-1.0, 1.0]: + blocks.append(_b(-2.0, -0.5, side * 4.6, 8.0, 1.0, 2.4, DARK)) + blocks.append(_b(-6.6, 0, side * 2.5, 2.5, 2.5, 2.4, STEEL)) + blocks.append(_b(3.5, 0, side * 3.4, 0.7, 4.0, 1.0, BRASS)) + _: + blocks = [_b(-1.5, -1.4, 0, 12.0, 2.0, 8.0, DARK), _b(-1.0, 0, 0, 11.0, 3.8, 7.0, STEEL), + _b(-2.2, 2.2, 0, 6.0, 1.1, 5.0, RIM), _b(-6.5, 0, 0, 2.0, 3.0, 6.5, DARK)] + for side in [-1.0, 1.0]: + blocks.append(_b(-2.8, -0.7, side * 4.4, 7.6, 1.1, 3.0, DARK)) + blocks.append(_b(-4.5, -0.3, side * 5.4, 6.4, 2.6, 3.0, STEEL)) + blocks.append(_b(-7.4, -0.3, side * 5.4, 1.0, 2.2, 2.6, BLACK)) + blocks.append(_b(-2.5, 1.2, side * 5.4, 1.0, 0.5, 2.8, BRASS)) + for x in [-4.2, -2.8, -1.4]: blocks.append(_b(x, 1.5, side * 2.6, 0.5, 0.5, 1.1, DARK)) + match family: + "beam": + blocks.append(_b(5.4, 0, 0, 10.0, 2.0, 4.5, DARK)) + for side in [-1.0, 1.0]: + blocks.append(_b(9.0, 0, side * 2.9, 7.0, 2.5, 1.3, BRASS)) + blocks.append(_b(12.0, 0, side * 2.9, 1.0, 3.2, 1.8, RIM)) + blocks.append(_b(3.1, 1.4, 0, 2.5, 0.8, 4.0, BRASS)) + "defense", "anti_drone": + blocks.append(_b(1.3, 2.0, 0, 5.8, 2.0, 5.8, DARK)) + blocks.append(_b(1.3, 3.1, 0, 4.2, 0.6, 4.2, STEEL)) + for z in ([-1.5, 1.5] if mark_two else [0.0]): + blocks.append(_b(6.0, 2.4, z, 8.0, 1.3, 1.3, DARK)) + blocks.append(_b(9.7, 2.4, z, 1.2, 2.0, 2.0, RIM)) + for side in [-1.0, 1.0]: + blocks.append(_b(-1.5, 0, side * 8.0, 9.0, 0.8, 2.0, BRASS)) + blocks.append(_b(-4.7, 2.0, side * 5.4, 0.5, 4.0, 0.5, DARK)) + if family == "anti_drone": + blocks.append(_b(4.5, 1.7, -4.2, 7.0, 1.0, 1.0, BRASS)) + blocks.append(_b(4.5, 1.7, 4.2, 7.0, 1.0, 1.0, BRASS)) + _: + for z in ([-1.8, 1.8] if mark_two else [0.0]): + blocks.append(_b(7.3, 0.3, z, 10.5, 2.0, 2.0, DARK)) + blocks.append(_b(11.7, 0.3, z, 1.4, 3.0, 3.0, RIM)) + blocks.append(_b(4.0, 0.7, 0, 1.2, 4.0, 6.0, BRASS)) + return blocks + + +func set_live(data: Dictionary) -> void: + live_data = data + visible = not bool(data.get("dead", false)) and (bool(data.get("deployed", false)) or not is_space) + var powered := bool(data.get("powered", true)) + firing = bool(data.get("firing", false)) if is_space else bool(data.get("fighting", false)) + thruster.visible = is_space and visible and powered + emitter.visible = visible and powered + var material: StandardMaterial3D = emitter.material_override + material.emission_energy_multiplier = 1.5 if firing else 0.25 + + +func animate(delta: float) -> void: + animation_time += delta + if thruster.visible: + thruster.scale.x = 0.88 + sin(animation_time * 28.0) * 0.12 + if tool != null: + tool.rotation.x = 0.0 + if firing: + tool.rotation.x = 0.7 + sin(animation_time * 9.0) * 0.5 + elif bool(live_data.get("repairing", false)): + tool.rotation.x = 0.55 + sin(animation_time * 11.0) * 0.35 + + +func _glow_mesh(blocks: Array, color: Color) -> MeshInstance3D: + # Interior robots have no exhaust; keep a harmless hidden mesh for the + # shared animation interface rather than creating an empty surface. + if blocks.is_empty(): blocks = [_b(0, 0, 0, 0.1, 0.1, 0.1, color)] + var node := Voxels.make(blocks) + var material: StandardMaterial3D = node.material_override + material.emission_enabled = true + material.emission = color + material.emission_energy_multiplier = 1.5 + return node + + +func _b(x: float, y: float, z: float, w: float, h: float, d: float, color: Color) -> Array: + return Voxels.block(Vector3(x, y, z) * UNIT, Vector3(w, h, d) * UNIT, color) diff --git a/scripts/voxel_drone.gd.uid b/scripts/voxel_drone.gd.uid new file mode 100644 index 0000000..ad5c520 --- /dev/null +++ b/scripts/voxel_drone.gd.uid @@ -0,0 +1 @@ +uid://cyhvbhf12ndf6 diff --git a/scripts/voxel_mesh.gd b/scripts/voxel_mesh.gd new file mode 100644 index 0000000..0dd4f21 --- /dev/null +++ b/scripts/voxel_mesh.gd @@ -0,0 +1,33 @@ +extends RefCounted + +# A model part is one mesh, regardless of how many colored blocks it contains. +static func make(blocks: Array) -> MeshInstance3D: + var surface := SurfaceTool.new() + surface.begin(Mesh.PRIMITIVE_TRIANGLES) + for block in blocks: + var center: Vector3 = block[0] + var half: Vector3 = Vector3(block[1]) * 0.5 + var color: Color = block[2] + var corners := [center + Vector3(-half.x, -half.y, -half.z), + center + Vector3(half.x, -half.y, -half.z), center + Vector3(half.x, half.y, -half.z), + center + Vector3(-half.x, half.y, -half.z), center + Vector3(-half.x, -half.y, half.z), + center + Vector3(half.x, -half.y, half.z), center + Vector3(half.x, half.y, half.z), + center + Vector3(-half.x, half.y, half.z)] + for face in [[0, 3, 2, 1], [5, 6, 7, 4], [4, 7, 3, 0], [1, 2, 6, 5], [3, 7, 6, 2], [4, 0, 1, 5]]: + var normal: Vector3 = (corners[face[1]] - corners[face[0]]).cross(corners[face[2]] - corners[face[0]]).normalized() + for index in [0, 1, 2, 0, 2, 3]: + surface.set_color(color) + surface.set_normal(normal) + surface.add_vertex(corners[face[index]]) + var node := MeshInstance3D.new() + node.mesh = surface.commit() + var material := StandardMaterial3D.new() + material.vertex_color_use_as_albedo = true + material.roughness = 0.88 + material.cull_mode = BaseMaterial3D.CULL_DISABLED + node.material_override = material + return node + + +static func block(center: Vector3, size: Vector3, color: Color) -> Array: + return [center, size, color] diff --git a/scripts/voxel_mesh.gd.uid b/scripts/voxel_mesh.gd.uid new file mode 100644 index 0000000..4952702 --- /dev/null +++ b/scripts/voxel_mesh.gd.uid @@ -0,0 +1 @@ +uid://1a3ux30lk6yx diff --git a/scripts/voxel_weapon.gd b/scripts/voxel_weapon.gd new file mode 100644 index 0000000..07ad2b2 --- /dev/null +++ b/scripts/voxel_weapon.gd @@ -0,0 +1,183 @@ +extends Node3D + +const Voxels = preload("res://scripts/voxel_mesh.gd") +var kind := "laser" +var weapon_name := "" +var body: Node3D +var emitter: MeshInstance3D +var charge_strip: MeshInstance3D +var charge_fill: MeshInstance3D +var charge_pips: Array[MeshInstance3D] = [] +var charge_level := 0 +var charge_max := 1 +var live_signature := "" +var muzzle_color := Color.WHITE +var charge := 0.0 +var powered := false +var recoil := 0.0 +var last_firing := false + + +func build(data: Dictionary) -> void: + for child in get_children(): + remove_child(child) + child.queue_free() + charge_pips.clear() + live_signature = "" + last_firing = false + recoil = 0.0 + kind = canonical_kind(str(data.get("kind", "laser"))) + weapon_name = str(data.get("name", "")) + body = Node3D.new() + add_child(body) + var steel := Color("778e9b") + var dark := Color("293944") + var black := Color("141f28") + var rim := Color("c2ccd0") + var blocks := [Voxels.block(Vector3(0, 0.0, 0), Vector3(0.12, 0.035, 0.12), dark), + Voxels.block(Vector3(0, -0.065, 0), Vector3(0.06, 0.115, 0.06), steel), + Voxels.block(Vector3(0, -0.13, 0), Vector3(0.14, 0.015, 0.12), dark), + Voxels.block(Vector3(-0.047, 0.007, 0), Vector3(0.013, 0.048, 0.086), rim)] + var glow := Color("ef4940") + var emitter_position := Vector3(0.171, 0.047, 0) + var emitter_size := Vector3(0.008, 0.023, 0.025) + var emitter_blocks: Array = [] + match kind: + "missile": + blocks.append(Voxels.block(Vector3(0.01, 0.048, 0), Vector3(0.17, 0.075, 0.118), steel)) + blocks.append(Voxels.block(Vector3(-0.06, 0.052, 0), Vector3(0.026, 0.099, 0.135), dark)) + for z in [-0.028, 0.028]: + blocks.append(Voxels.block(Vector3(0.098, 0.05, z), Vector3(0.025, 0.050, 0.049), rim)) + blocks.append(Voxels.block(Vector3(0.112, 0.05, z), Vector3(0.006, 0.035, 0.033), black)) + blocks.append(Voxels.block(Vector3(-0.04, 0.091, z), Vector3(0.10, 0.013, 0.015), Color("e3ae68"))) + emitter_blocks.append(Voxels.block(Vector3(0.117, 0.05, z), Vector3(0.008, 0.027, 0.027), Color("ffbf5c"))) + emitter_position = Vector3(0.117, 0.05, 0) + emitter_size = Vector3(0.008, 0.011, 0.077) + glow = Color("ffbf5c") + "beam": + blocks.append(Voxels.block(Vector3(0.0, 0.044, 0), Vector3(0.12, 0.065, 0.08), steel)) + blocks.append(Voxels.block(Vector3(0.075, 0.064, 0), Vector3(0.15, 0.028, 0.10), dark)) + for z in [-0.045, 0.045]: + blocks.append(Voxels.block(Vector3(0.13, 0.054, z), Vector3(0.12, 0.045, 0.018), rim)) + blocks.append(Voxels.block(Vector3(0.152, 0.056, z), Vector3(0.017, 0.061, 0.028), Color("a79566"))) + blocks.append(Voxels.block(Vector3(-0.032, 0.066, 0), Vector3(0.038, 0.024, 0.10), rim)) + emitter_position = Vector3(0.17, 0.055, 0) + emitter_size = Vector3(0.011, 0.025, 0.062) + glow = Color("ffdd75") + "bomb": + blocks.append(Voxels.block(Vector3(0, 0.06, 0), Vector3(0.12, 0.10, 0.10), steel)) + blocks.append(Voxels.block(Vector3(0.075, 0.06, 0), Vector3(0.055, 0.07, 0.075), dark)) + for z in [-0.06, 0.06]: + blocks.append(Voxels.block(Vector3(0, 0.06, z), Vector3(0.065, 0.07, 0.018), Color("b591b7"))) + blocks.append(Voxels.block(Vector3(0.039, 0.073, 0), Vector3(0.014, 0.087, 0.098), rim)) + blocks.append(Voxels.block(Vector3(0.008, 0.106, 0), Vector3(0.049, 0.016, 0.049), dark)) + emitter_position = Vector3(0.109, 0.062, 0) + emitter_size = Vector3(0.011, 0.039, 0.040) + glow = Color("cf83ff") + "ion": + blocks.append(Voxels.block(Vector3(0, 0.04, 0), Vector3(0.115, 0.06, 0.09), steel)) + for x in [0.045, 0.075, 0.105]: + blocks.append(Voxels.block(Vector3(x, 0.044, 0), Vector3(0.018, 0.075, 0.075), Color("548dba"))) + blocks.append(Voxels.block(Vector3(x, 0.084, 0), Vector3(0.016, 0.006, 0.059), rim)) + blocks.append(Voxels.block(Vector3(0.083, 0.044, 0), Vector3(0.12, 0.025, 0.025), dark)) + blocks.append(Voxels.block(Vector3(0.146, 0.044, 0), Vector3(0.016, 0.045, 0.045), dark)) + emitter_position = Vector3(0.155, 0.044, 0) + emitter_size = Vector3(0.006, 0.028, 0.028) + glow = Color("6ae0ff") + "flak": + blocks.append(Voxels.block(Vector3(0, 0.048, 0), Vector3(0.12, 0.075, 0.115), steel)) + for z in [-0.038, 0.0, 0.038]: + blocks.append(Voxels.block(Vector3(0.098, 0.05, z), Vector3(0.145, 0.027, 0.027), dark)) + blocks.append(Voxels.block(Vector3(0.157, 0.05, z), Vector3(0.022, 0.035, 0.035), rim)) + emitter_blocks.append(Voxels.block(Vector3(0.170, 0.05, z), Vector3(0.006, 0.018, 0.018), Color("f9a451"))) + for z in [-0.070, 0.070]: + blocks.append(Voxels.block(Vector3(-0.024, 0.041, z), Vector3(0.082, 0.057, 0.028), Color("988e72"))) + emitter_position = Vector3(0.170, 0.05, 0) + emitter_size = Vector3(0.006, 0.018, 0.090) + glow = Color("f9a451") + _: + blocks.append(Voxels.block(Vector3(0, 0.041, 0), Vector3(0.13, 0.065, 0.085), steel)) + var barrels := 2 if weapon_name.contains("BURST") or weapon_name.contains("DUAL") else 1 + for i in range(barrels): + var z := (i - (barrels - 1) * 0.5) * 0.043 + blocks.append(Voxels.block(Vector3(0.10, 0.047, z), Vector3(0.14, 0.033, 0.026), dark)) + blocks.append(Voxels.block(Vector3(0.165, 0.047, z), Vector3(0.02, 0.046, 0.04), rim)) + emitter_blocks.append(Voxels.block(Vector3(0.176, 0.047, z), Vector3(0.007, 0.025, 0.026), glow)) + blocks.append(Voxels.block(Vector3(-0.025, 0.079, 0), Vector3(0.07, 0.013, 0.055), Color("a06155"))) + blocks.append(Voxels.block(Vector3(0.074, 0.046, 0), Vector3(0.019, 0.055, 0.094), steel)) + if weapon_name.contains("HEAVY"): + blocks.append(Voxels.block(Vector3(0.122, 0.047, 0), Vector3(0.086, 0.057, 0.071), steel)) + # Cooling fins and a green charge strip on the upper armor remain readable + # from a tabletop angle, rather than a subtle glow hidden inside a barrel. + for x in [-0.052, -0.035, -0.018]: + for z in [-0.052, 0.052]: + blocks.append(Voxels.block(Vector3(x, 0.037, z), Vector3(0.008, 0.055, 0.012), rim)) + for x in [-0.046, 0.049]: + blocks.append(Voxels.block(Vector3(x, 0.101, 0), Vector3(0.014, 0.043, 0.034), steel)) + body.add_child(Voxels.make(blocks)) + muzzle_color = glow + if emitter_blocks.is_empty(): + emitter_blocks.append(Voxels.block(emitter_position, emitter_size, glow)) + emitter = Voxels.make(emitter_blocks) + var material: StandardMaterial3D = emitter.material_override + material.emission_enabled = true + material.emission = glow + material.emission_energy_multiplier = 0.0 + body.add_child(emitter) + charge_strip = Voxels.make([Voxels.block(Vector3(0.004, 0.122, 0), Vector3(0.125, 0.012, 0.047), dark), + Voxels.block(Vector3(0.004, 0.129, -0.024), Vector3(0.127, 0.005, 0.006), rim), + Voxels.block(Vector3(0.004, 0.129, 0.024), Vector3(0.127, 0.005, 0.006), rim)]) + body.add_child(charge_strip) + charge_fill = _indicator(Vector3.ZERO, Vector3(1.0, 0.006, 0.033)) + charge_fill.position.y = 0.131 + body.add_child(charge_fill) + charge_max = clampi(int(data.get("charge_max", 1)), 1, 8) + if charge_max > 1: + for i in range(charge_max): + var pip := _indicator(Vector3.ZERO, Vector3(0.014, 0.007, 0.013)) + pip.position = Vector3(-0.046 + i * 0.018, 0.134, -0.037) + body.add_child(pip) + charge_pips.append(pip) + set_live(data) + + +func _indicator(center: Vector3, size: Vector3) -> MeshInstance3D: + var indicator: MeshInstance3D = Voxels.make([Voxels.block(center, size, Color.WHITE)]) + var material: StandardMaterial3D = indicator.material_override + material.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED + material.emission_enabled = true + material.emission = Color("84dc7c") + material.emission_energy_multiplier = 0.3 + return indicator + + +func set_live(data: Dictionary) -> void: + powered = bool(data.get("powered", false)) + charge = clampf(float(data.get("charge_fraction", float(data.get("charge", 0)) / maxf(float(data.get("cooldown", 1)), 0.001))), 0.0, 1.0) + charge_level = clampi(int(data.get("charge_level", 0)), 0, charge_max) + var firing := bool(data.get("firing", false)) + if firing and not last_firing: + recoil = 0.018 + last_firing = firing + var next := str(powered) + ":" + str(charge) + ":" + str(charge_level) + if live_signature != next: + live_signature = next + var material: StandardMaterial3D = emitter.material_override + material.emission_energy_multiplier = (0.15 + charge * 1.8) if powered else 0.0 + emitter.visible = powered + charge_fill.visible = powered and charge > 0.001 + charge_fill.scale.x = maxf(0.001, charge * 0.11) + charge_fill.position.x = -0.051 + charge * 0.055 + var indicator_material: StandardMaterial3D = charge_fill.material_override + indicator_material.albedo_color = Color("92e87d") if charge > 0.995 else Color("b8d97a") + for i in range(charge_pips.size()): + charge_pips[i].material_override.albedo_color = Color("92e87d") if powered and i < charge_level else Color("2a4245") + + +func animate(delta: float) -> void: + recoil = maxf(0.0, recoil - delta * 0.12) + body.position.x = -recoil + + +static func canonical_kind(native_kind: String) -> String: + return {"missiles": "missile", "burst": "flak"}.get(native_kind.to_lower(), native_kind.to_lower()) diff --git a/scripts/voxel_weapon.gd.uid b/scripts/voxel_weapon.gd.uid new file mode 100644 index 0000000..457570f --- /dev/null +++ b/scripts/voxel_weapon.gd.uid @@ -0,0 +1 @@ +uid://d4fvj2g2umjrg diff --git a/tools/benchmark_effects.gd b/tools/benchmark_effects.gd new file mode 100644 index 0000000..cbc6b34 --- /dev/null +++ b/tools/benchmark_effects.gd @@ -0,0 +1,76 @@ +extends SceneTree + +const BEAMS := 12 +const SAMPLES := 120 + + +func _initialize() -> void: + call_deferred("_run") + + +func _run() -> void: + Engine.max_fps = 90 + var stage := Node3D.new() + root.add_child(stage) + var camera := Camera3D.new() + stage.add_child(camera) + camera.position = Vector3(0.0, 1.4, 4.0) + camera.look_at(Vector3.ZERO) + camera.current = true + var effects: Node3D = load("res://scripts/combat_effects.gd").new() + stage.add_child(effects) + var results := {} + for mode in ["mutable_mesh", "unit_transform"]: + var phase := Node3D.new() + stage.add_child(phase) + var segments: Array[MeshInstance3D] = [] + var changed := {"count": 0} + var watched := {} + for i in range(BEAMS): + var beam: MeshInstance3D = effects._projectile_visual("beam", Color.ORANGE) + phase.add_child(beam) + for child in beam.get_children(): + var segment := child as MeshInstance3D + if mode == "mutable_mesh": + segment.mesh = segment.mesh.duplicate() + segments.append(segment) + if not watched.has(segment.mesh.get_instance_id()): + watched[segment.mesh.get_instance_id()] = true + segment.mesh.changed.connect(func(): changed["count"] += 1) + var total_us := 0 + for frame in range(SAMPLES + 20): + if frame == 20: + changed["count"] = 0 + var start_us := Time.get_ticks_usec() + for i in range(segments.size()): + var start := Vector3(-1.5, float(i / 2) * 0.025 - 0.2, 0.0) + var end := Vector3(1.5, sin(frame * 0.027 + i * 0.01) * 0.65, cos(frame * 0.023) * 0.28) + var radius := 0.004 if i % 2 == 0 else 0.012 + if mode == "mutable_mesh": + _mutable_segment(segments[i], start, end, radius) + else: + effects._segment(segments[i], start, end, radius) + if frame >= 20: + total_us += Time.get_ticks_usec() - start_us + await process_frame + await RenderingServer.frame_post_draw + results[mode] = {"cpu_update_us_per_frame": float(total_us) / SAMPLES, + "mesh_resource_changes": changed["count"], "mesh_resources": watched.size()} + phase.queue_free() + await process_frame + print("BEAM_BENCHMARK beams=", BEAMS, " samples=", SAMPLES, " ", JSON.stringify(results)) + quit() + + +func _mutable_segment(node: MeshInstance3D, start: Vector3, end: Vector3, radius: float) -> void: + var direction := end - start + var length := direction.length() + var axis := direction / length + var reference := Vector3.RIGHT if absf(axis.dot(Vector3.UP)) > 0.98 else Vector3.UP + var side := reference.cross(axis).normalized() + node.global_basis = Basis(side, axis, side.cross(axis)) + node.global_position = (start + end) * 0.5 + var mesh := node.mesh as CylinderMesh + mesh.height = length + mesh.top_radius = radius + mesh.bottom_radius = radius diff --git a/tools/capture_environment.gd b/tools/capture_environment.gd new file mode 100644 index 0000000..07b3a84 --- /dev/null +++ b/tools/capture_environment.gd @@ -0,0 +1,37 @@ +extends SceneTree + + +func _initialize() -> void: + call_deferred("_capture") + + +func _capture() -> void: + var stage := Node3D.new() + root.add_child(stage) + var world := WorldEnvironment.new() + var environment := Environment.new() + environment.background_mode = Environment.BG_COLOR + environment.background_color = Color(0.002, 0.003, 0.008) + environment.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR + environment.ambient_light_color = Color(0.5, 0.6, 0.75) + environment.ambient_light_energy = 0.7 + world.environment = environment + stage.add_child(world) + var light := DirectionalLight3D.new() + light.rotation_degrees = Vector3(-35.0, 20.0, 0.0) + stage.add_child(light) + var camera := Camera3D.new() + stage.add_child(camera) + camera.position = Vector3(0.0, 1.8, 2.0) + camera.look_at(Vector3(0.0, 1.8, -1.0)) + camera.current = true + var space: Node3D = load("res://scripts/space_environment.gd").new() + stage.add_child(space) + space.build() + for kind in ["clear", "asteroid", "sun", "storm", "nebula", "pulsar"]: + space.apply_state({"hazard": kind}) + await create_timer(0.3).timeout + await RenderingServer.frame_post_draw + root.get_texture().get_image().save_png(ProjectSettings.globalize_path("res://local_game_data/environment_%s.png" % kind)) + print("ENVIRONMENT_CAPTURE PASS") + quit() diff --git a/tools/capture_environment.gd.uid b/tools/capture_environment.gd.uid new file mode 100644 index 0000000..e3181bc --- /dev/null +++ b/tools/capture_environment.gd.uid @@ -0,0 +1 @@ +uid://uxghhh678b68 diff --git a/tools/capture_input.gd b/tools/capture_input.gd new file mode 100644 index 0000000..dad447b --- /dev/null +++ b/tools/capture_input.gd @@ -0,0 +1,36 @@ +extends SceneTree +func _initialize() -> void: + call_deferred("run") +func run() -> void: + var scene: Node3D = load("res://main.tscn").instantiate() + root.add_child(scene) + await process_frame + scene.set_process(false) + scene.xr_active = true + scene.left_hand.transform = scene.camera.transform + scene.nav_panel.transform = Transform3D(Basis.IDENTITY,Vector3(-0.3,-0.05,-1.05)) + scene.nav_panel.scale = Vector3.ONE * 0.6 + scene.nav_panel.visible = true + scene.right_hand.transform = scene.camera.transform * Transform3D(Basis.IDENTITY,Vector3(0.25,-0.22,-0.3)) + # A simulated tracked ray uses the same drawing and picking path as XR. + var tracker := XRControllerTracker.new() + tracker.type = XRServer.TRACKER_CONTROLLER + tracker.name = "right_hand" + tracker.set_pose("aim",scene.right_hand.transform,Vector3.ZERO,Vector3.ZERO,XRPose.XR_TRACKING_CONFIDENCE_HIGH) + XRServer.add_tracker(tracker) + for page in ["navigation","shortcuts","help","wheel"]: + scene.wheel.visible = page == "wheel" + if page == "wheel": + scene._refresh_wheel() + scene.wheel.global_transform = scene.camera.global_transform + scene.wheel.global_position += -scene.camera.global_basis.z * 0.85 + scene.camera.global_basis.x * 0.30 + scene.wheel.choose(Vector2(0,1)) + scene.nav_page = page if page != "wheel" else "navigation" + scene._refresh_navigation() + scene.right_hand.look_at(scene.nav_panel.global_position,Vector3.UP) + scene._update_pointer() + for i in range(8): await process_frame + await RenderingServer.frame_post_draw + root.get_texture().get_image().save_png("res://local_game_data/input-"+page+".png") + XRServer.remove_tracker(tracker) + quit() diff --git a/tools/capture_input.gd.uid b/tools/capture_input.gd.uid new file mode 100644 index 0000000..2ffe2be --- /dev/null +++ b/tools/capture_input.gd.uid @@ -0,0 +1 @@ +uid://bm17rm3w3bb0g diff --git a/tools/capture_live.gd b/tools/capture_live.gd new file mode 100644 index 0000000..2777245 --- /dev/null +++ b/tools/capture_live.gd @@ -0,0 +1,13 @@ +extends SceneTree + +func _initialize() -> void: + call_deferred("capture") + +func capture() -> void: + var scene: Node3D = load("res://main.tscn").instantiate() + root.add_child(scene) + await create_timer(2.0).timeout + await RenderingServer.frame_post_draw + root.get_texture().get_image().save_png(ProjectSettings.globalize_path("res://local_game_data/live_preview.png")) + print("LIVE_RENDER ready=", scene.bridge_connected, " crew=", scene.player_ship.crew_nodes.size()) + quit() diff --git a/tools/capture_live.gd.uid b/tools/capture_live.gd.uid new file mode 100644 index 0000000..4ef35d8 --- /dev/null +++ b/tools/capture_live.gd.uid @@ -0,0 +1 @@ +uid://w2lkox2jrhi0 diff --git a/tools/capture_models.gd b/tools/capture_models.gd new file mode 100644 index 0000000..4c7a100 --- /dev/null +++ b/tools/capture_models.gd @@ -0,0 +1,108 @@ +extends SceneTree + +const CrewModel = preload("res://scripts/voxel_crew.gd") +const WeaponModel = preload("res://scripts/voxel_weapon.gd") +const ShipModel = preload("res://scripts/ship_model.gd") +const DroneModel = preload("res://scripts/voxel_drone.gd") +const UiAssets = preload("res://scripts/ftl_ui_assets.gd") + + +func _initialize() -> void: + call_deferred("_run") + + +func _run() -> void: + root.size = Vector2i(1600, 900) + var weapons_only := OS.get_cmdline_user_args().has("--weapons") + var drones_only := OS.get_cmdline_user_args().has("--drones") + var crew_only := OS.get_cmdline_user_args().has("--crew") + var world := Node3D.new() + root.add_child(world) + var environment := WorldEnvironment.new() + var settings := Environment.new() + settings.background_mode = Environment.BG_COLOR + settings.background_color = Color("111b25") + settings.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR + settings.ambient_light_color = Color("d4e7f2") + settings.ambient_light_energy = 0.65 + environment.environment = settings + world.add_child(environment) + var light := DirectionalLight3D.new() + light.rotation_degrees = Vector3(-40, -30, 0) + light.light_energy = 1.6 + world.add_child(light) + var camera := Camera3D.new() + camera.projection = Camera3D.PROJECTION_ORTHOGONAL + camera.size = 3.0 if drones_only else 2.5 if crew_only else 1.8 if weapons_only else 2.1 + camera.position = Vector3(0.6, 3.4 if drones_only or crew_only else 2.2, 4.3) + world.add_child(camera) + camera.look_at(Vector3(0, 0.2, 0.2)) + if drones_only: + var kinds := ["combat", "beam", "defense", "hacking", "shield", "boarding", "anti_drone", "repair", "battle", "ion_boarder"] + for i in range(kinds.size()): + var drone := DroneModel.new() + drone.position = Vector3((i % 5 - 2) * 0.75, 0.22, -0.95 if i < 5 else 0.95) + drone.scale = Vector3.ONE * 2.8 + world.add_child(drone) + var exterior: bool = kinds[i] not in ["repair", "battle", "ion_boarder"] + drone.build({"kind": kinds[i], "species": kinds[i], "name": str(kinds[i]).to_upper() + "_1", "is_space": exterior, + "deployed": true, "powered": true, "repairing": kinds[i] == "repair", "fighting": kinds[i] == "battle"}) + # Gallery poses are presentation fixtures, not synthetic game state. + drone.rotation.y = -0.55 if exterior else PI + 0.2 + drone.animate(0.3) + _label(world, str(kinds[i]).replace("_", " ").to_upper(), drone.position + Vector3(0, -0.16, 0.27)) + for i in range(CrewModel.SPECIES.size()): + if weapons_only or drones_only: + break + var model := CrewModel.new() + model.position = Vector3((i % 4 - 1.5) * (0.78 if crew_only else 0.63), 0.1, + (-0.95 if i < 4 else 0.95) if crew_only else (-0.65 if i < 4 else 0.4)) + model.scale = Vector3.ONE * 3.0 + world.add_child(model) + model.build(CrewModel.SPECIES[i]) + model.facing = PI + 0.3 + var states := ["work", "repair", "run", "fight"] + var state: String = states[i % 4] + model.set_state({"working": state == "work", "repairing": state == "repair", "running": state == "run", "fighting": state == "fight"}) + model.animate(0.3) + model.face_viewer(camera.position - model.position, 1.0) + _label(world, str(CrewModel.SPECIES[i]).to_upper() + "\n" + state, model.position + Vector3(0, -0.06, 0.20)) + for i in range(6): + if drones_only or crew_only: break + var weapon := WeaponModel.new() + weapon.position = Vector3((i % 3 - 1) * 0.88, 0.12, -0.52 if i < 3 else 0.70) if weapons_only else Vector3((i - 2.5) * 0.48, 0.04, 1.35) + weapon.scale = Vector3.ONE * (2.8 if weapons_only else 1.8) + world.add_child(weapon) + var kinds := ["laser", "ion", "beam", "missile", "bomb", "flak"] + var fraction := float(i) / 5.0 if weapons_only else 1.0 + weapon.build({"kind": kinds[i], "powered": true, "charge_fraction": fraction, "charge_max": 4 if kinds[i] == "laser" else 1, "charge_level": 2 if kinds[i] == "laser" else 0}) + var label := Label3D.new() + label.font = UiAssets.font("body") + label.outline_size = 0 + label.texture_filter = BaseMaterial3D.TEXTURE_FILTER_NEAREST + label.text = str(kinds[i]).to_upper() + (" • %d%% CHARGED" % roundi(fraction * 100) if weapons_only else "") + label.font_size = 36 + label.pixel_size = 0.0025 + label.position = weapon.position + Vector3(0, -0.02, 0.30 if weapons_only else 0.13) + label.billboard = BaseMaterial3D.BILLBOARD_ENABLED + world.add_child(label) + for i in range(12): + await process_frame + await RenderingServer.frame_post_draw + var path := ProjectSettings.globalize_path("res://local_game_data/drone_gallery.png" if drones_only else "res://local_game_data/crew_gallery.png" if crew_only else "res://local_game_data/weapon_gallery.png" if weapons_only else "res://local_game_data/model_gallery.png") + root.get_texture().get_image().save_png(path) + print("MODEL_CAPTURE ", path) + quit() + + +func _label(world: Node3D, text: String, location: Vector3) -> void: + var label := Label3D.new() + label.text = text + label.font = UiAssets.font("body") + label.outline_size = 0 + label.font_size = 38 + label.pixel_size = 0.0025 + label.position = location + label.texture_filter = BaseMaterial3D.TEXTURE_FILTER_NEAREST + label.billboard = BaseMaterial3D.BILLBOARD_ENABLED + world.add_child(label) diff --git a/tools/capture_native_models.gd b/tools/capture_native_models.gd new file mode 100644 index 0000000..01f5245 --- /dev/null +++ b/tools/capture_native_models.gd @@ -0,0 +1,140 @@ +extends SceneTree + +const ShipModel = preload("res://scripts/ship_model.gd") + + +func _initialize() -> void: + call_deferred("_run") + + +func _run() -> void: + root.size = Vector2i(1280, 720) + var args := OS.get_cmdline_user_args() + var state_path := "res://local_game_data/live_state.json" + var output_path := "" + for argument in args: + if argument.begins_with("--state="): + state_path = argument.trim_prefix("--state=") + elif argument.begins_with("--output="): + output_path = argument.trim_prefix("--output=") + var data: Dictionary = JSON.parse_string(FileAccess.get_file_as_string(state_path)) + var side := "enemy" if args.has("--enemy") else "player" + if not data.has(side): + push_error("No native %s ship in the current snapshot" % side) + quit(1) + return + var native_ship: Dictionary = data[side] + var ship := ShipModel.new() + root.add_child(ship) + ship.build(str(native_ship.get("asset_key", native_ship.layout)), side == "enemy") + ship.inspect_rooms = true + ship.apply_live(native_ship) + ship.simulation_paused = true + var environment := WorldEnvironment.new() + var settings := Environment.new() + settings.background_mode = Environment.BG_COLOR + settings.background_color = Color("101822") + settings.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR + settings.ambient_light_color = Color("d4e7f2") + settings.ambient_light_energy = 0.45 + environment.environment = settings + root.add_child(environment) + var light := DirectionalLight3D.new() + light.rotation_degrees = Vector3(-35, -25, 0) + light.light_energy = 1.5 + root.add_child(light) + var camera := Camera3D.new() + camera.projection = Camera3D.PROJECTION_ORTHOGONAL + camera.size = maxf(2.0, maxf(ship.shield_radii.x, ship.shield_radii.z) * 2.35) + var view_center := Vector3(ship.shield_center.x, 0, ship.shield_center.z) + camera.position = view_center + Vector3(1.2, 2.3, 2.1) + root.add_child(camera) + camera.look_at(view_center) + ship.set_viewer_position(camera.global_position) + for i in range(12): + await process_frame + await RenderingServer.frame_post_draw + var path := ProjectSettings.globalize_path("res://local_game_data/native_models%s_preview.png" % ("_enemy" if side == "enemy" else "")) + if not output_path.is_empty(): + path = ProjectSettings.globalize_path(output_path) + root.get_texture().get_image().save_png(path) + print("NATIVE_MODEL_CAPTURE ", side, " ", path, " crew=", ship.crew_nodes.size(), " doors=", ship.door_nodes.size(), " drones=", ship.drone_nodes.size(), " hull_voxels=", ship.hull_voxel_count) + var weapon_charge_state: Array = [] + for slot in range(mini(ship.weapon_nodes.size(), native_ship.get("weapons", []).size())): + var weapon: Node3D = ship.weapon_nodes[slot] + weapon_charge_state.append({"slot": slot, "name": weapon.weapon_name, "powered": weapon.powered, + "charge_fraction": weapon.charge, "stored_shots": weapon.charge_level, "stored_max": weapon.charge_max}) + var door_tiers: Dictionary = {} + for door in ship.door_nodes.values(): + var tier: int = door.get_meta("blast_tier", 0) + door_tiers[tier] = int(door_tiers.get(tier, 0)) + 1 + print("NATIVE_VISUAL_STATE weapons=", weapon_charge_state, " fire_tiles=", ship.room_hazards.fire_points.size(), + " breach_tiles=", ship.room_hazards.breach_points.size(), " effective_door_tiers=", door_tiers) + var crew_facing: Array = [] + for miniature in ship.crew_nodes.values(): + var model: Node3D = miniature.get_node("Model") + if model.has_method("face_viewer"): + var direction: Vector3 = ship.to_local(camera.global_position) - miniature.position + crew_facing.append({"id": miniature.get_meta("crew_id"), "state": model.animation_state, + "visible": miniature.visible, + "yaw_error": absf(wrapf(model.rotation.y - atan2(-direction.x, -direction.z), -PI, PI)), + "body_pitch": model.rotation.x, "body_roll": model.rotation.z}) + elif model.get("family") != null: + crew_facing.append({"id": miniature.get_meta("crew_id"), "drone_family": model.family, "visible": miniature.visible}) + print("NATIVE_CREW_FACING ", crew_facing) + var drone_models: Array = [] + for native_drone in native_ship.get("drones", []): + var drone_id := str(native_drone.get("id", "")) + if ship.drone_nodes.has(drone_id): + var model: Node3D = ship.drone_nodes[drone_id] + drone_models.append({"id": drone_id, "name": model.drone_name, "family": model.family, + "position_error": model.position.distance_to(ship.pixel_point(native_drone, 0.3)), + "angle_error": absf(wrapf(model.rotation.y + deg_to_rad(float(native_drone.get("angle", 0))), -PI, PI))}) + print("NATIVE_DRONE_MODELS ", drone_models) + var outside_footprint := 0 + var outside_volume := 0 + var outside_native_footprint := 0 + var max_native_radial := 0.0 + var shape: Dictionary = native_ship.get("shield_shape", {}) + var native_radii := Vector2(float(shape.get("a", 0)) / 35.0 * ship.TILE, float(shape.get("b", 0)) / 35.0 * ship.TILE) + var hull: MultiMeshInstance3D = ship.get_node_or_null("ExtrudedHull") + if hull != null: + for i in range(hull.multimesh.instance_count): + var transform := hull.multimesh.get_instance_transform(i) + if native_radii.x > 0 and native_radii.y > 0: + var delta := transform.origin - ship.shield_center + var radial := Vector2(delta.x / native_radii.x, delta.z / native_radii.y).length() + max_native_radial = maxf(max_native_radial, radial) + outside_native_footprint += 1 if radial > 1.0 else 0 + var local := (transform.origin - ship.shield_center) / ship.shield_radii + if local.x * local.x + local.z * local.z > 1.0: + outside_footprint += 1 + local.y -= transform.basis.y.length() * 0.5 / ship.shield_radii.y + if local.length_squared() > 1.0: + outside_volume += 1 + var max_room_alignment_px := 0.0 + var max_mount_alignment_px := 0.0 + var native_weapons: Array = native_ship.get("weapons", []) + var source_mounts: Array = ship.layout_data.get("weapon_mounts", []) + for slot in range(mini(native_weapons.size(), source_mounts.size())): + var native_mount: Dictionary = native_weapons[slot].get("mount", {}) + if not native_mount.is_empty(): + var source_mount: Dictionary = source_mounts[slot] + max_mount_alignment_px = maxf(max_mount_alignment_px, Vector2(float(native_mount.x) - float(source_mount.x), float(native_mount.y) - float(source_mount.y)).length()) + var outside_corners := 0 + var max_corner_radius := 0.0 + for point in ship.shield_fit_points: + var radius: float = ((point - ship.shield_center) / ship.shield_radii).length() + max_corner_radius = maxf(max_corner_radius, radius) + outside_corners += 1 if radius > 1.001 else 0 + for room in native_ship.get("rooms", []): + if ship.room_nodes.has(int(room["id"])): + var native_center: Vector3 = ship.pixel_point(room.get("center", {}), 0.065) + var error: Vector3 = native_center - ship.room_nodes[int(room["id"])].position + max_room_alignment_px = maxf(max_room_alignment_px, Vector2(error.x, error.z).length() * 35.0 / ship.TILE) + print("SHIELD_MODEL_GEOMETRY native=", shape, " local_center=", ship.shield_center, " radii=", ship.shield_radii, " padding=", ship.shield_fit_padding, + " native_outside_footprint=", outside_native_footprint, " native_radial_max=", max_native_radial, " outside_footprint=", outside_footprint, + " outside_keel=", outside_volume, " outside_hull_wall_corners=", outside_corners, " corner_radial_max=", max_corner_radius, + " room_alignment_max_px=", max_room_alignment_px, " weapon_mount_alignment_max_px=", max_mount_alignment_px, + " texture=", ship.layout_data.get("image_source_path", ""), " image_rect=", ship.layout_data.get("image_rect", {})) + quit() diff --git a/tools/capture_ship_details.gd b/tools/capture_ship_details.gd new file mode 100644 index 0000000..c4e9d1a --- /dev/null +++ b/tools/capture_ship_details.gd @@ -0,0 +1,94 @@ +extends SceneTree + +const ShipModel = preload("res://scripts/ship_model.gd") +const HullBar = preload("res://scripts/enemy_hull_bar.gd") +const Hazards = preload("res://scripts/room_hazards.gd") +const Voxels = preload("res://scripts/voxel_mesh.gd") +const UiAssets = preload("res://scripts/ftl_ui_assets.gd") + + +func _initialize() -> void: + call_deferred("_run") + + +func _run() -> void: + # Visual fixture only; no bridge, input, game state, or save writes. + root.size = Vector2i(1400, 900) + var ship := ShipModel.new() + root.add_child(ship) + var hazards := Hazards.new() + ship.add_child(hazards) + hazards.build() + hazards.set_live(ship, {"rooms": [{"visible": true, "fire_tiles": [{"x": -90, "y": 115}], "breach_tiles": [{"x": 90, "y": 115}]}]}) + hazards.animate(0.42) + for i in range(4): + var door_center := Vector3((i - 1.5) * 0.49, 0.22, -0.02) + var frame := Voxels.make([Voxels.block(Vector3(-0.077, 0, 0), Vector3(0.025, 0.11, 0.034), Color("516b77")), + Voxels.block(Vector3(0.077, 0, 0), Vector3(0.025, 0.11, 0.034), Color("516b77")), + Voxels.block(Vector3(0, 0.06, 0), Vector3(0.179, 0.01, 0.034), Color("516b77"))]) + frame.position = door_center + frame.scale = Vector3.ONE * 2.0 + root.add_child(frame) + for side in [-1, 1]: + var leaf: MeshInstance3D = ship._make_door_leaf(i) + leaf.position = door_center + Vector3(side * 0.031 * 2.0, 0, 0) + leaf.scale = Vector3.ONE * 2.0 + root.add_child(leaf) + _label(["STANDARD", "BLAST", "IMPROVED", "CREW BOOST"][i], Vector3((i - 1.5) * 0.49, 0.12, 0.10)) + var floor := MeshInstance3D.new() + var box := BoxMesh.new() + box.size = Vector3(1.6, 0.025, 0.39) + floor.mesh = box + floor.position = Vector3(0, 0.065, 0.59) + var material := StandardMaterial3D.new() + material.albedo_color = Color("66767c") + floor.material_override = material + root.add_child(floor) + _label("FIRE", Vector3(-0.46, 0.065, 0.80)) + _label("HULL BREACH", Vector3(0.46, 0.065, 0.80)) + var environment := WorldEnvironment.new() + var settings := Environment.new() + settings.background_mode = Environment.BG_COLOR + settings.background_color = Color("101923") + settings.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR + settings.ambient_light_color = Color("d4e7f2") + settings.ambient_light_energy = 0.45 + environment.environment = settings + root.add_child(environment) + var light := DirectionalLight3D.new() + light.rotation_degrees = Vector3(-40, -25, 0) + light.light_energy = 1.6 + root.add_child(light) + var camera := Camera3D.new() + camera.projection = Camera3D.PROJECTION_ORTHOGONAL + camera.size = 1.32 + camera.position = Vector3(0.55, 1.8, 2.5) + root.add_child(camera) + camera.look_at(Vector3(0, 0.27, 0.33)) + var life := HullBar.new() + root.add_child(life) + life.build() + life.set_state({"hull": 12, "hull_max": 20}) + life.position = Vector3(0, 0.56, -0.15) + life.face_viewer(camera.position) + for i in range(12): + await process_frame + await RenderingServer.frame_post_draw + var path := ProjectSettings.globalize_path("res://local_game_data/ship_details_fixture.png") + root.get_texture().get_image().save_png(path) + print("SHIP_DETAIL_FIXTURE ", path, " fire_tiles=", hazards.fire_points.size(), " breach_tiles=", hazards.breach_points.size(), " door_tiers=0,1,2,3") + quit() + + +func _label(text: String, location: Vector3) -> void: + var label := Label3D.new() + label.text = text + label.font = UiAssets.font("body") + label.outline_size = 0 + label.position = location + label.font_size = 38 + label.texture_filter = BaseMaterial3D.TEXTURE_FILTER_NEAREST + label.pixel_size = 0.0016 + label.billboard = BaseMaterial3D.BILLBOARD_ENABLED + label.no_depth_test = true + root.add_child(label) diff --git a/tools/create_action_map.gd b/tools/create_action_map.gd new file mode 100644 index 0000000..1ec4d7a --- /dev/null +++ b/tools/create_action_map.gd @@ -0,0 +1,52 @@ +extends SceneTree + +# Explicit Steam Frame bindings; Godot's generic/Touch bindings remain fallbacks. +func _initialize() -> void: + var map := OpenXRActionMap.new() + map.create_default_action_sets() + var actions := {} + for action in map.get_action_set(0).get_actions(): + actions[action.resource_name] = action + var custom := {"page_left":"left","page_right":"left","tactical_button":"left", + "power_remove":"left","power_previous":"right","power_next":"right","wheel_button":"right"} + for name in custom: + var action := OpenXRAction.new() + action.resource_name = name + action.localized_name = str(name).replace("_"," ").capitalize() + action.action_type = OpenXRAction.OPENXR_ACTION_BOOL + action.toplevel_paths = PackedStringArray(["/user/hand/" + custom[name]]) + map.get_action_set(0).add_action(action) + actions[name] = action + var profile := OpenXRInteractionProfile.new() + profile.interaction_profile_path = "/interaction_profiles/valve/frame_controller_valve" + var bindings: Array[OpenXRIPBinding] = [] + for hand in ["left", "right"]: + var prefix := "/user/hand/%s/" % hand + var paths := {"default_pose": "input/aim/pose", "aim_pose": "input/aim/pose", + "grip_pose": "input/grip/pose", "trigger": "input/trigger/value", + "trigger_click": "input/trigger/click", "grip": "input/squeeze/value", + "grip_click": "input/squeeze/click", "primary": "input/thumbstick", + "primary_click": "input/thumbstick/click", "haptic": "output/haptic", + "by_button": "input/dpad_up/click" if hand == "left" else "input/b/click", + "menu_button": "input/view/click" if hand == "left" else "input/menu/click"} + # The vendor profile in the installed SteamVR Frame driver declares X/Y + # on the right controller. The left controller has the Dpad and View. + if hand == "left": + paths.merge({"page_left":"input/dpad_left/click","page_right":"input/dpad_right/click", + "tactical_button":"input/dpad_down/click","power_remove":"input/bumper/click"}) + else: + paths.merge({"ax_button":"input/a/click","power_previous":"input/x/click", + "power_next":"input/y/click","wheel_button":"input/bumper/click"}) + for name in paths: + var binding := OpenXRIPBinding.new() + binding.action = actions[name] + binding.binding_path = prefix + paths[name] + bindings.append(binding) + profile.set_bindings(bindings) + map.add_interaction_profile(profile) + var path := "res://openxr_action_map.tres" + for argument in OS.get_cmdline_user_args(): + if argument.begins_with("--output="): path = argument.trim_prefix("--output=") + var error := ResourceSaver.save(map, path) + print("FTLVR action map saved: ", error) + quit(error) diff --git a/tools/create_action_map.gd.uid b/tools/create_action_map.gd.uid new file mode 100644 index 0000000..9258f8e --- /dev/null +++ b/tools/create_action_map.gd.uid @@ -0,0 +1 @@ +uid://dmh8m7mtywriy diff --git a/tools/extract_ftl.py b/tools/extract_ftl.py new file mode 100644 index 0000000..d4aad7c --- /dev/null +++ b/tools/extract_ftl.py @@ -0,0 +1,214 @@ +"""Read a user's FTL PKG and write local-only data for the tabletop prototype. + +This intentionally does not patch the game or copy game data into the repository. +Works with a Steam or GOG ftl.dat that uses the 1.6.x PKG layout. +""" + +from __future__ import annotations + +import argparse +import json +import struct +import xml.etree.ElementTree as ET +from pathlib import Path + + +class Pkg: + def __init__(self, path: Path): + self.path = path + self.entries: dict[str, tuple[int, int, int, int]] = {} + with path.open("rb") as source: + header = source.read(16) + if len(header) != 16 or header[:4] != b"PKG\n": + raise ValueError("Not an FTL PKG archive") + header_size, entry_size, count, names_size = struct.unpack(">HHII", header[4:]) + if header_size != 16 or entry_size != 20 or count > 100_000: + raise ValueError("Unsupported FTL PKG index") + records = [] + for _ in range(count): + record = source.read(20) + if len(record) != 20: + raise ValueError("Truncated FTL PKG index") + _hash = int.from_bytes(record[0:4], "big") + flags = record[4] + name_offset = int.from_bytes(record[5:8], "big") + offset, size, raw_size = struct.unpack(">III", record[8:20]) + records.append((flags, name_offset, offset, size, raw_size)) + names = source.read(names_size) + source.seek(0, 2) + archive_size = source.tell() + for flags, name_offset, offset, size, raw_size in records: + if name_offset >= len(names) or offset + size > archive_size: + raise ValueError("FTL PKG index points outside archive") + end = names.find(b"\x00", name_offset) + if end < 0: + raise ValueError("Unterminated FTL PKG path") + name = names[name_offset:end].decode("ascii") + self.entries[name] = (flags, offset, size, raw_size) + + def read(self, name: str) -> bytes: + flags, offset, size, raw_size = self.entries[name] + if flags != 0 or raw_size != size: + raise ValueError(f"Compressed or unsupported PKG entry: {name}") + with self.path.open("rb") as source: + source.seek(offset) + data = source.read(size) + if len(data) != size: + raise ValueError(f"Truncated PKG entry: {name}") + return data + + +def parse_layout(raw: bytes) -> dict: + tokens = raw.decode("utf-8-sig").split() + counts = {"X_OFFSET": 1, "Y_OFFSET": 1, "VERTICAL": 1, "ELLIPSE": 4, + "ROOM": 5, "DOOR": 5} + layout: dict = {"rooms": [], "doors": []} + cursor = 0 + while cursor < len(tokens): + tag = tokens[cursor].upper() + cursor += 1 + if tag not in counts: + raise ValueError(f"Unknown layout token: {tag}") + count = counts[tag] + if cursor + count > len(tokens): + raise ValueError(f"Truncated {tag} record") + values = [int(value) for value in tokens[cursor:cursor + count]] + cursor += count + if tag == "ROOM": + room_id, x, y, w, h = values + layout["rooms"].append({"id": room_id, "x": x, "y": y, "w": w, "h": h}) + elif tag == "DOOR": + x, y, a, b, vertical = values + layout["doors"].append({"x": x, "y": y, "a": a, "b": b, + "vertical": bool(vertical)}) + else: + layout[tag.lower()] = values[0] if count == 1 else values + if not layout["rooms"]: + raise ValueError("Layout contains no rooms") + return layout + + +def system_rooms(blueprints: bytes, layout_name: str) -> tuple[str, dict[str, str]]: + root = ET.fromstring(blueprints) + candidates = [node for node in root.findall(".//shipBlueprint") + if node.get("layout") == layout_name] + if not candidates: + return "", {} + preferred = "PLAYER_SHIP_HARD" if layout_name == "kestral" else "" + blueprint = next((node for node in candidates if node.get("name") == preferred), candidates[0]) + systems = {} + for node in blueprint.findall("./systemList/*"): + if node.get("start", "true") == "false": + continue + room = node.get("room") + if room is not None: + systems[room] = node.tag + return blueprint.get("name", ""), systems + + +def png_size(raw: bytes) -> tuple[int, int]: + if len(raw) < 24 or raw[:8] != b"\x89PNG\r\n\x1a\n" or raw[12:16] != b"IHDR": + raise ValueError("Invalid ship PNG header") + return struct.unpack(">II", raw[16:24]) + + +UI_FONTS = ("c&c.font", "JustinFont10.font", "JustinFont11Bold.font") + + +def extract_fonts(pkg: Pkg, destination: Path) -> list[str]: + """Keep original SIL bitmap fonts local; the runtime decodes their atlas. + + No font converter, system font installation or Godot import is required. + """ + written = [] + for name in UI_FONTS: + source = f"fonts/{name}" + if source not in pkg.entries: + continue + raw = pkg.read(source) + if len(raw) < 24 or raw[:5] != b"FONT\x01": + raise ValueError(f"Unsupported native bitmap font: {source}") + path = destination / source + path.parent.mkdir(parents=True, exist_ok=True) + # SIL .font is not Godot's BMFont .font. Load it via our runtime decoder + # and keep the editor from trying to import it as an unrelated format. + (path.parent / ".gdignore").write_text("", encoding="utf-8") + path.write_bytes(raw) + written.append(source) + return written + + +def build_ship(pkg: Pkg, name: str, destination: Path, image_name: str | None = None, + *, native_image_rect: dict | None = None) -> dict: + import re + image_name = image_name or name + if not all(re.fullmatch(r'[A-Za-z0-9_-]+', part) for part in (name, image_name)): + raise ValueError('Invalid ship asset identifier') + key = name if image_name == name else f'{name}__{image_name}' + layout = parse_layout(pkg.read(f"data/{name}.txt")) + root = ET.fromstring(pkg.read(f"data/{name}.xml")) + image_node = root.find("img") + if image_node is None: + raise ValueError(f"No ship image rectangle for {name}") + layout["name"] = name + layout["image_rect"] = {key: int(image_node.attrib[key]) for key in ("x", "y", "w", "h")} + if native_image_rect: + rectangle = {key: int(native_image_rect[key]) for key in ("x", "y", "w", "h")} + if not (0 < rectangle["w"] <= 8192 and 0 < rectangle["h"] <= 8192): + raise ValueError("Invalid native ship image dimensions") + layout["image_rect"] = rectangle + layout["image_rect_source"] = "native_ship_image" + layout["blueprint"], layout["systems"] = system_rooms(pkg.read("data/blueprints.xml"), name) + layout["weapon_mounts"] = [dict(node.attrib) for node in root.findall("./weaponMounts/mount")] + candidates = [f"img/ship/{image_name}_base.png", + f"img/ships_noglow/{image_name}_base.png", + f"img/ships_glow/{image_name}_base.png"] + available = [(path, pkg.read(path)) for path in candidates if path in pkg.entries] + if not available: + raise ValueError(f"No base image for {name}") + # Glow and noglow sprites often have different transparent borders. Match + # the dimensions FTL actually renders instead of stretching a cropped PNG + # over the larger XML rectangle and extruding a second, smaller silhouette. + rectangle = layout["image_rect"] + expected_size = (rectangle["w"], rectangle["h"]) + image_path, image_bytes = next((entry for entry in available if png_size(entry[1]) == expected_size), available[0]) + layout["texture_size"] = list(png_size(image_bytes)) + layout["image_source_path"] = image_path + destination.mkdir(parents=True, exist_ok=True) + (destination / f"{key}.png").write_bytes(image_bytes) + (destination / f"{key}.json").write_text(json.dumps(layout, indent=2), encoding="utf-8") + return {"name": name, "rooms": len(layout["rooms"]), "doors": len(layout["doors"]), + "blueprint": layout["blueprint"], + "image_path": image_path} + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("archive", type=Path, help="Path to your Steam or GOG ftl.dat") + parser.add_argument("--output", type=Path, + default=Path(__file__).resolve().parents[1] / "local_game_data") + parser.add_argument("--ship", action="append", dest="ships", + help="Ship layout name; repeat for more ships") + args = parser.parse_args() + pkg = Pkg(args.archive) + ships = args.ships or ["kestral", "rebel_long"] + manifest = {"source": str(args.archive), "ships": []} + for ship in ships: + manifest["ships"].append(build_ship(pkg, ship, args.output)) + ui_paths = [name for name in pkg.entries if name.endswith(".png") and ( + name.startswith(("img/statusUI/", "img/systemUI/", "img/icons/", "img/combatUI/")) + or name in {"img/box_subsystems4.png", "img/box_weapons_bottom4.png", + "img/box_weapons_bottom_label.png", "img/box_weapons_autofire_base.png", + "img/Text_pause1.png", "img/Text_pause2.png"})] + for name in ui_paths: + destination = args.output / "ui" / name + destination.parent.mkdir(parents=True, exist_ok=True) + destination.write_bytes(pkg.read(name)) + manifest["ui_assets"] = len(ui_paths) + manifest["fonts"] = extract_fonts(pkg, args.output) + (args.output / "manifest.json").write_text(json.dumps(manifest, indent=2), encoding="utf-8") + print(json.dumps(manifest, indent=2)) + + +if __name__ == "__main__": + main() diff --git a/tools/hud_alpha.py b/tools/hud_alpha.py new file mode 100644 index 0000000..77806b7 --- /dev/null +++ b/tools/hud_alpha.py @@ -0,0 +1,42 @@ +"""Remove only edge-connected cleared black pixels, retaining black UI ink.""" +import numpy as np + + +def clear_world_alpha(pixels): + black = ~np.any(pixels[:, :, :3], axis=2) + height, width = black.shape + parents, edge, runs = [], [], [] + previous = [] + + def root(i): + while parents[i] != i: + parents[i] = parents[parents[i]] + i = parents[i] + return i + + for y in range(height): + changes = np.flatnonzero(np.diff(np.pad(black[y].astype(np.int8), (1, 1)))) + current = [] + cursor = 0 + for x0, x1 in zip(changes[::2], changes[1::2]): + i = len(parents) + parents.append(i) + edge.append(y in (0, height-1) or x0 == 0 or x1 == width) + current.append((int(x0), int(x1), i)) + runs.append((y, int(x0), int(x1), i)) + while cursor < len(previous) and previous[cursor][1] <= x0: + cursor += 1 + k = cursor + while k < len(previous) and previous[k][0] < x1: + a, b = root(i), root(previous[k][2]) + if a != b: + parents[b] = a + edge[a] = edge[a] or edge[b] + k += 1 + previous = current + result = pixels.copy() + result[:, :, 3] = 255 + for y, x0, x1, i in runs: + if edge[root(i)]: + result[y, x0:x1, 3] = 0 + return result diff --git a/tools/input_harness.gd b/tools/input_harness.gd new file mode 100644 index 0000000..dedbd80 --- /dev/null +++ b/tools/input_harness.gd @@ -0,0 +1,4 @@ +extends "res://scripts/main.gd" +var commands: Array = [] +func _command(action: String, data: Dictionary) -> void: + commands.append({"action": action, "data": data}) diff --git a/tools/input_harness.gd.uid b/tools/input_harness.gd.uid new file mode 100644 index 0000000..ffa9938 --- /dev/null +++ b/tools/input_harness.gd.uid @@ -0,0 +1 @@ +uid://dsrpbn3l2ka44 diff --git a/tools/lab_formats.py b/tools/lab_formats.py new file mode 100644 index 0000000..d939cb3 --- /dev/null +++ b/tools/lab_formats.py @@ -0,0 +1,107 @@ +"""Local FTL lab converters. Never distribute their game-derived output.""" +import struct +import zlib +from pathlib import Path + +from extract_ftl import Pkg + + +def path_hash(name: str) -> int: + """SIL PKG rotate/XOR path hash, documented by Slipstream PkgPack.""" + value = 0 + for byte in name.lower().encode('ascii'): + value = (((value << 27) | (value >> 5)) ^ byte) & 0xFFFFFFFF + return value + + +def write_pkg(source: Path, destination: Path, replacements: dict[str, bytes]): + pkg = Pkg(source) + names = sorted(set(pkg.entries) | set(replacements), key=lambda n: (path_hash(n), n.lower())) + name_blob = b''.join(n.encode('ascii') + b'\0' for n in names) + offset = 16 + 20 * len(names) + len(name_blob) + name_offset = 0 + index = [] + for name in names: + size = len(replacements[name]) if name in replacements else pkg.entries[name][2] + index.append(struct.pack('>IIIII', path_hash(name), name_offset, offset, size, size)) + offset += size + name_offset += len(name.encode('ascii')) + 1 + if len(name_blob) >= 1 << 24 or offset >= 1 << 32: + raise ValueError('PKG exceeds format bounds') + if source.resolve() == destination.resolve(): + raise ValueError('Refusing to overwrite source archive') + with destination.open('wb') as output: + output.write(b'PKG\n' + struct.pack('>HHII', 16, 20, len(names), len(name_blob))) + output.write(b''.join(index)) + output.write(name_blob) + for name in names: + output.write(replacements[name] if name in replacements else pkg.read(name)) + check = Pkg(destination) + for name, payload in replacements.items(): + if check.read(name) != payload: + raise ValueError(f'PKG round trip failed: {name}') + + +def apply_bps(source: bytes, patch: bytes) -> bytes: + """Apply a BPS patch with source, patch, and target CRC checks.""" + if patch[:4] != b'BPS1' or len(patch) < 16: + raise ValueError('Not a BPS patch') + source_crc, target_crc, patch_crc = struct.unpack('= len(patch) - 12: + raise ValueError('Truncated BPS number') + byte = patch[cursor] + cursor += 1 + value += (byte & 127) * shift + if byte & 128: + return value + shift <<= 7 + value += shift + + source_size, target_size, metadata_size = number(), number(), number() + if source_size != len(source) or target_size > 128 * 1024 * 1024: + raise ValueError('Unsupported BPS sizes') + cursor += metadata_size + target = bytearray() + source_relative = target_relative = 0 + while len(target) < target_size: + action = number() + mode, count = action & 3, (action >> 2) + 1 + if len(target) + count > target_size: + raise ValueError('BPS target overrun') + if mode == 0: + start = len(target) + if start + count > len(source): + raise ValueError('BPS source overrun') + target.extend(source[start:start + count]) + elif mode == 1: + if cursor + count > len(patch) - 12: + raise ValueError('BPS literal overrun') + target.extend(patch[cursor:cursor + count]) + cursor += count + else: + distance = number() + delta = (distance >> 1) * (-1 if distance & 1 else 1) + if mode == 2: + source_relative += delta + if source_relative < 0 or source_relative + count > len(source): + raise ValueError('BPS source copy out of bounds') + target.extend(source[source_relative:source_relative + count]) + source_relative += count + else: + target_relative += delta + for _ in range(count): + if not 0 <= target_relative < len(target): + raise ValueError('BPS target copy out of bounds') + target.append(target[target_relative]) + target_relative += 1 + if cursor != len(patch) - 12 or zlib.crc32(target) != target_crc: + raise ValueError('BPS target CRC or trailing data mismatch') + return bytes(target) diff --git a/tools/lab_settings.py b/tools/lab_settings.py new file mode 100644 index 0000000..0f4c6a1 --- /dev/null +++ b/tools/lab_settings.py @@ -0,0 +1,21 @@ +"""Set only the isolated lab's canvas and reserved virtual store hotkey.""" +import re + + +def configure(lab): + path=lab/'settings.ini' + text=path.read_text() if path.exists() else '' + values={'fullscreen':0,'last_fullscreen':0,'manual':1,'screen_x':1280, + 'screen_y':720,'windowed':1,'stretched':0,'store':291,'pause':32, + 'weapon1':49,'weapon2':50,'weapon3':51,'weapon4':52, + 'crew_all':113,'loadPositions':13,'savePositions':47,'autofire':118, + 'open':122,'close':120,'force_autofire':306, + 'shields':97,'engines':115,'oxygen':102,'medbay':100,'weapons':119,'drones':101, + 'teleporter':103,'cloaking':104,'mind':107,'hacking':108,'artillery':121, + 'activate_cloak':99,'send_tele':116,'ret_tele':114,'start_hacking':110, + 'mindControl':109,'activate_battery':98} + for key,value in values.items(): + pattern=rf'(?m)^{key}=.*$' + if re.search(pattern,text): text=re.sub(pattern,f'{key}={value}',text) + else: text+=f'\n{key}={value}\n' + path.write_text(text) diff --git a/tools/launch.py b/tools/launch.py new file mode 100644 index 0000000..719fcc9 --- /dev/null +++ b/tools/launch.py @@ -0,0 +1,114 @@ +"""Launch the verified isolated game and OpenXR client together on Windows.""" +import argparse +import hashlib +import importlib.util +import json +import os +import subprocess +import sys +import time +import winreg +import zipfile +from datetime import datetime +from pathlib import Path +from lab_settings import configure + +PROJECT = Path(__file__).resolve().parents[1] + + +def preflight(config, desktop=False): + for name in ('lab', 'godot', 'hooks'): + if not Path(config[name]).exists(): raise ValueError(f'Missing {name}: {config[name]}') + for module in ('frida', 'PIL', 'numpy'): + if importlib.util.find_spec(module) is None: raise ValueError(f'Missing Python dependency: {module}') + lab = Path(config['lab']) + marker = json.loads((lab/'ftlvr-lab.json').read_text()) + hooks = json.loads(Path(config['hooks']).read_text()) + if not all(name in hooks.get('rvas',{}) for name in ('GetShiftState','GetCtrlState','ForceAutofireFlag')): + raise ValueError('Regenerate lab hooks.json with tools/resolve_hooks.py for controller modifiers') + if not all(name in hooks.get('rvas',{}) for name in ('OnTextInput','OnTextEvent','TextInputOnRender','TextInputStart')): + raise ValueError('Regenerate lab hooks.json with tools/resolve_hooks.py for native renaming') + if not all(name in hooks.get('rvas',{}) for name in ('CommandGuiRenderStatic','CommandGuiRenderPause','TabbedWindowOnRender','ChoiceBoxOnRender','MouseControlOnRender','StarMapOnRender')): + raise ValueError('Regenerate lab hooks.json with tools/resolve_hooks.py for live native HUD capture') + if not marker.get('activated') or hashlib.sha1((lab/'FTLGame.exe').read_bytes()).hexdigest() != hooks['sha1']: + raise ValueError('Lab is not activated or its executable fingerprint changed') + if not (lab/'Hyperspace.dll').exists(): raise ValueError('Hyperspace is missing') + status = PROJECT/'local_game_data/bridge_status.json' + if status.exists(): + previous = json.loads(status.read_text()) + if previous.get('connected') and time.time()-previous.get('heartbeat',0)<3: + raise ValueError('An FTL VR bridge is already running. Close that session first.') + runtime = '' + try: + with winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE,r'SOFTWARE\Khronos\OpenXR\1') as key: + runtime = winreg.QueryValueEx(key,'ActiveRuntime')[0] + except OSError: pass + if not desktop and ('steamxr' not in runtime.lower() or not Path(runtime).exists()): + raise ValueError('Select SteamVR as the OpenXR runtime in SteamVR settings, then retry') + return {'lab':str(lab), 'runtime':runtime, 'mode':'desktop' if desktop else 'VR'} + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--config',type=Path,default=PROJECT/'local_game_data/launcher.json') + parser.add_argument('--desktop',action='store_true') + parser.add_argument('--check',action='store_true') + parser.add_argument('--smoke',action='store_true',help='Desktop launch and graceful exit after 3 seconds') + args=parser.parse_args() + if args.smoke: args.desktop=True + config=json.loads(args.config.read_text()) + print(json.dumps(preflight(config,args.desktop),indent=2),flush=True) + if args.check: return + local=PROJECT/'local_game_data'; local.mkdir(exist_ok=True) + lab=Path(config['lab']) + # Snapshot only this mod's separate save profile before each launch. + saves=Path(os.environ['USERPROFILE'])/'Documents/My Games/FasterThanLight' + backup=lab/'save-backups'; backup.mkdir(exist_ok=True) + with zipfile.ZipFile(backup/(datetime.now().strftime('%Y%m%d-%H%M%S')+'.zip'),'w',zipfile.ZIP_DEFLATED) as archive: + for path in saves.glob('hs_ftlvr_*'): + if path.is_file(): archive.write(path,path.name) + if (lab/'settings.ini').exists(): archive.write(lab/'settings.ini','settings.ini') + configure(lab) + stop=local/'launcher.stop' + stop.unlink(missing_ok=True) + logs=local/'logs'; logs.mkdir(exist_ok=True) + with (logs/'bridge.log').open('w') as bridge_log, (logs/'client.log').open('w') as client_log: + bridge=subprocess.Popen([sys.executable,str(PROJECT/'tools/run_bridge.py'), + '--lab',str(lab),'--hooks',config['hooks'],'--allow-input','--stop-file',str(stop)], + cwd=PROJECT,stdout=bridge_log,stderr=subprocess.STDOUT,creationflags=subprocess.CREATE_NO_WINDOW) + client=None + try: + deadline=time.monotonic()+55 + status=local/'bridge_status.json' + while time.monotonic().*?', 'ftlvr2', config, flags=re.S) + config = config.replace('', '') + replacements['data/hyperspace.xml'] = config.encode('utf-8') + replacements['data/ftlvr_bridge.lua'] = (Path(__file__).parents[1] / 'bridge/ftlvr_bridge.lua').read_bytes() + mod_file = lab / 'tabletop-bridge.ftl' + with zipfile.ZipFile(mod_file, 'w', zipfile.ZIP_DEFLATED) as output: + for name, payload in replacements.items(): + output.writestr(name, payload) + staging = lab / 'prepared-resources' + staging.mkdir(exist_ok=True) + shutil.copy2(game / 'ftl.dat', staging / 'ftl.dat') + subprocess.run([str(args.mod_manager.resolve()), 'patch', '--data-dir', str(staging), + str((args.hyperspace / 'Hyperspace.ftl').resolve()), str(mod_file)], check=True) + shutil.copy2(staging / 'ftl.dat', lab / 'ftl.vr.prepared') + marker.write_text(json.dumps({'source_game': str(game), 'source_sha1': STEAM_1614_SHA1, + 'rollback_sha1': STEAM_169_SHA1, 'save_prefix': 'ftlvr', + 'activated': args.activate}, indent=2), encoding='utf-8') + if args.activate: + shutil.copy2(lab / 'FTLGame_1.6.9.prepared', lab / 'FTLGame.exe') + shutil.copy2(lab / 'ftl.vr.prepared', lab / 'ftl.dat') + binary_dir = args.hyperspace / 'Windows - Extract these files into where FTLGame.exe is' + for name in ['Hyperspace.dll', 'xinput1_4.dll']: + shutil.copy2(binary_dir / name, lab / name) + configure(lab) + print(json.dumps({'lab': str(lab), 'prepared_entries': len(replacements), 'activated': args.activate})) + + +if __name__ == '__main__': + main() diff --git a/tools/profile_main.gd b/tools/profile_main.gd new file mode 100644 index 0000000..0e3e058 --- /dev/null +++ b/tools/profile_main.gd @@ -0,0 +1,16 @@ +extends "res://scripts/main.gd" + +var profile_poll_ms: Array[float] = [] +var profile_process_ms: Array[float] = [] + + +func _poll_bridge_state() -> void: + var started := Time.get_ticks_usec() + super._poll_bridge_state() + profile_poll_ms.append(float(Time.get_ticks_usec() - started) / 1000.0) + + +func _process(delta: float) -> void: + var started := Time.get_ticks_usec() + super._process(delta) + profile_process_ms.append(float(Time.get_ticks_usec() - started) / 1000.0) diff --git a/tools/profile_tactical.gd b/tools/profile_tactical.gd new file mode 100644 index 0000000..9266886 --- /dev/null +++ b/tools/profile_tactical.gd @@ -0,0 +1,96 @@ +extends SceneTree + +var elapsed := 0.0 +var sample_seconds := 10.0 +var counts := {"screen": 0, "hud": 0} +var frame_times: Array[float] = [] +var surfaces: Array[Node3D] = [] +var output_name := "tactical-client-profile.json" +var full_main := false +var capture_ages: Array[float] = [] +var process_times: Array[float] = [] +var main: Node3D +var mock_tracker: XRControllerTracker + + +func _initialize() -> void: + Engine.max_fps = 90 + for value in OS.get_cmdline_user_args(): + if value.begins_with("--output="): output_name = value.trim_prefix("--output=") + if value.begins_with("--seconds="): sample_seconds = float(value.trim_prefix("--seconds=")) + if value == "--full-main": full_main = true + call_deferred("_run") + + +func _run() -> void: + if full_main: + main = load(ProjectSettings.get_setting("application/run/main_scene")).instantiate() + main.set_script(load("res://tools/profile_main.gd")) + root.add_child(main) + # Exercise the complete VR presentation and CPU update paths in a + # desktop Vulkan viewport. This does not measure stereo headset FPS. + main.xr_active = true + main.initial_table_placed = true + mock_tracker = XRControllerTracker.new() + mock_tracker.type = XRServer.TRACKER_CONTROLLER + mock_tracker.name = "left_hand" + mock_tracker.set_pose("default", Transform3D(Basis.IDENTITY, Vector3(-0.4, 2.05, 2.0)), Vector3.ZERO, Vector3.ZERO, XRPose.XR_TRACKING_CONFIDENCE_HIGH) + XRServer.add_tracker(mock_tracker) + main._poll_bridge_state() + surfaces.assign([main.map_surface, main.hud_surface]) + else: + var camera := Camera3D.new() + camera.position = Vector3(0, 0, 3) + camera.current = true + root.add_child(camera) + for entry in [["screen", "screen_frame.png"], ["hud", "hud_frame.png"]]: + var surface: Node3D = load("res://scripts/hud_surface.gd").new() + surface.frame_file = entry[1] + root.add_child(surface) + surfaces.append(surface) + for i in range(surfaces.size()): + surfaces[i].canvas.texture_changed.connect(_count.bind("screen" if i == 0 else "hud")) + await create_timer(1.0).timeout + counts = {"screen": 0, "hud": 0} + capture_ages.clear() + if full_main: + main.profile_poll_ms.clear() + main.profile_process_ms.clear() + var started := Time.get_ticks_usec() + while elapsed < sample_seconds: + var before := Time.get_ticks_usec() + await process_frame + frame_times.append(float(Time.get_ticks_usec() - before) / 1000.0) + process_times.append(Performance.get_monitor(Performance.TIME_PROCESS) * 1000.0) + elapsed = float(Time.get_ticks_usec() - started) / 1000000.0 + frame_times.sort() + capture_ages.sort() + process_times.sort() + var report := {"seconds": elapsed, "screen_texture_updates": counts.screen, + "hud_texture_updates": counts.hud, "screen_delivered_fps": counts.screen / elapsed, + "hud_delivered_fps": counts.hud / elapsed, "client_fps": frame_times.size() / elapsed, + "client_frame_ms_p95": frame_times[int(frame_times.size() * 0.95)], + "gpu": RenderingServer.get_video_adapter_name(), "renderer": RenderingServer.get_current_rendering_method(), + "full_main": full_main, "stereo_headset_measurement": false, + "client_process_ms_p95": process_times[int(process_times.size() * 0.95)], + "screen_capture_age_ms_p95": capture_ages[int(capture_ages.size() * 0.95)] if not capture_ages.is_empty() else -1.0, + "screen_transport_size": str(surfaces[0].canvas.frame_image.get_size()) if surfaces[0].canvas.frame_image != null else "missing", + "screen_viewport_size": str(surfaces[0].viewport.size)} + if full_main: + main.profile_poll_ms.sort() + main.profile_process_ms.sort() + report.native_poll_ms_p95 = main.profile_poll_ms[int(main.profile_poll_ms.size() * 0.95)] + report.native_poll_ms_max = main.profile_poll_ms.back() + report.main_cpu_ms_p95 = main.profile_process_ms[int(main.profile_process_ms.size() * 0.95)] + report.main_cpu_ms_max = main.profile_process_ms.back() + var file := FileAccess.open("res://local_game_data/" + output_name, FileAccess.WRITE) + file.store_string(JSON.stringify(report, "\t")) + file.close() + print("TACTICAL_CLIENT_PROFILE ", JSON.stringify(report)) + quit() + + +func _count(stream: String) -> void: + counts[stream] += 1 + if stream == "screen" and not surfaces.is_empty() and surfaces[0].canvas.capture_time_unix > 0: + capture_ages.append((Time.get_unix_time_from_system() - surfaces[0].canvas.capture_time_unix) * 1000.0) diff --git a/tools/profile_tactical.py b/tools/profile_tactical.py new file mode 100644 index 0000000..1b7e7b8 --- /dev/null +++ b/tools/profile_tactical.py @@ -0,0 +1,46 @@ +"""Measure native counters and actual published files during a scoped QA run.""" +import argparse +import json +import time +from pathlib import Path + + +def read_json(path): + for _ in range(20): + try: + return json.loads(path.read_text(encoding='utf-8')) + except (PermissionError,json.JSONDecodeError): + time.sleep(.01) + raise RuntimeError('Could not read profile state') + + +def measure(local,seconds): + first=read_json(local/'bridge_status.json')['native_capture']['performance'] + changes={name:0 for name in ('screen_frame.png','hud_frame.png','screen_frame.rgba','hud_frame.rgba')} + previous={name:(local/name).stat().st_mtime_ns if (local/name).exists() else 0 for name in changes} + start=time.monotonic() + while time.monotonic()-start void: + call_deferred("record") + +func record() -> void: + var scene: Node3D = load("res://main.tscn").instantiate() + root.add_child(scene) + await create_timer(1.0).timeout + if not scene.bridge_connected: + push_error("Live bridge required for this recording") + quit(1) + return + var directory := ProjectSettings.globalize_path("res://local_game_data/recording") + DirAccess.make_dir_recursive_absolute(directory) + for i in range(200): + var progress := float(i) / 199.0 + # Move the encounter to inspect its actual geometry; HUD stays on camera. + scene.tabletop_root.rotation.y = sin(progress * TAU) * 0.9 + if i == 80: + scene.enemy_ship.inspect_rooms = true + scene.enemy_ship.apply_live(scene.enemy_ship.live_data) + await RenderingServer.frame_post_draw + root.get_texture().get_image().save_png(directory.path_join("frame-%04d.png" % i)) + await create_timer(0.1).timeout + print("LIVE_RECORDING frames=200 fps=10 simulation_paused=", scene.paused) + quit() diff --git a/tools/record_live.gd.uid b/tools/record_live.gd.uid new file mode 100644 index 0000000..af786d1 --- /dev/null +++ b/tools/record_live.gd.uid @@ -0,0 +1 @@ +uid://bwpyp25yh1tmy diff --git a/tools/resolve_hooks.py b/tools/resolve_hooks.py new file mode 100644 index 0000000..2a1608c --- /dev/null +++ b/tools/resolve_hooks.py @@ -0,0 +1,159 @@ +"""Resolve community-documented CApp signatures against the owned lab binary.""" +import argparse +import hashlib +import json +import re +import struct +from pathlib import Path + +import capstone + + +def resolve(executable: Path, signatures: Path) -> dict: + data = executable.read_bytes() + pe = struct.unpack_from('= 1.0: + self.previews[name] = image + self.preview_times[name] = now + + def _preview_frames(self): + while not self.closed.wait(0.02): + with self.lock: + previews, self.previews = self.previews, {} + for name, image in previews.items(): + temporary = self.local / (name + '.pending.png') + image.save(temporary, compress_level=1) + replace_when_available(temporary, self.local / (name + '.png')) + + def close(self): + self.closed.set() + self.preview_worker.join(timeout=2) + + +def decode_state_line(line): + prefix = 'FTLVR_STATE ' + if prefix not in line: + return None + content = line.split(prefix, 1)[1].strip() + result = json.loads(content) + if result.get('protocol') != 2 or result.get('source') != 'hyperspace': + raise ValueError('Unsupported FTL snapshot protocol') + if not isinstance(result.get('shots'), list): + result['shots'] = [] + if not isinstance(result.get('projectiles'), list): + result['projectiles'] = [] + for side in ('player','enemy'): + if isinstance(result.get(side),dict): + for field in ('rooms','crew','weapons','doors','drones','system_status'): + if not isinstance(result[side].get(field),list): result[side][field]=[] + for room in result[side]['rooms']: + for field in ('fire_tiles','breach_tiles'): + if not isinstance(room.get(field),list): room[field]=[] + if isinstance(result.get('dialog'),dict) and not isinstance(result['dialog'].get('choices'),list): + result['dialog']['choices']=[] + # vCrewList ownership/current-ship membership can differ during boarding. + # Render and select each surviving crew member on its actual current ship. + crew_by_id={c['id']:c for side in ('player','enemy') for c in (result.get(side) or {}).get('crew',[])} + for side,ship_id in (('player',0),('enemy',1)): + if isinstance(result.get(side),dict): + result[side]['crew']=[c for c in crew_by_id.values() if c.get('ship')==ship_id] + drones_by_id={d['id']:d for side in ('player','enemy') for d in (result.get(side) or {}).get('drones',[])} + for side,ship_id in (('player',0),('enemy',1)): + if isinstance(result.get(side),dict): + result[side]['drones']=[d for d in drones_by_id.values() if d.get('space')==ship_id] + return result + + +def room_pixel(state, ship, room_id): + data = state.get(ship) or {} + room = next((r for r in data.get('rooms', []) if int(r['id']) == int(room_id)), None) + if room is None: + raise ValueError('Room is not in the current FTL ship') + origin = state[ship + '_origin'] + center = room['center'] + return round(origin['x'] + center['x']), round(origin['y'] + center['y']) + + +def full_screen_capture(state): + # Native shops, upgrades, crew and equipment are world panels, never HUDs. + return not state.get('ready') or state.get('ui_mode','menu') == 'menu' + + +def world_gameplay_available(state): + """Tactical is a view of gameplay; real native modal windows block commands.""" + return bool(state.get('ready') and not state.get('blocking_ui') and not state.get('event_open') and not state.get('map_open') + and not state.get('panel_open') and not state.get('transition') + and (state.get('ui_mode')=='game' or + (state.get('tactical') and state.get('ui_mode')=='screen'))) + + +def remove_enemy_hud(pixels): + """Remove the entire native target frame, even on cached/out-of-combat HUDs.""" + result=pixels.copy() + result[40:602,872:,3]=0 + return result + + +def remove_pause_overlay(pixels): + """The VR client shows pause from native state, never from a cached glyph.""" + result=pixels.copy() + result[520:598,510:770,3]=0 + return result + + +def native_window_frame(pixels): + """Keep native window contours, including detached boxes, and discard HUD. + + The native renderer already clips ships and environment. Edge-connected + cleared black becomes transparent; enclosed black window ink is retained. + SELL includes a detached drop box and an open gap at screen center. Select + substantial framed components, then keep their enclosed ink; requiring an + opaque center pixel loses SELL, while filling whole bounds leaks PAUSED. + """ + import numpy as np + result=pixels.copy() + # At the fixed native 1280x720 viewport, floating window tabs start at + # y=83. Remove the separate top HUD before finding components: its jump + # button glow can touch the tab frame and otherwise expand the crop. + result[:80,:,3]=0 + # Native windows have bright connected borders. The dimmed background HUD + # can overlap a SELL drop box; exclude its dark connector pixels while + # locating frames, then retain the original ink inside each chosen frame. + opaque=np.max(result[:,:,:3],axis=2)>=80 + opaque[:80,:]=False + center=(opaque.shape[1]//2,opaque.shape[0]//2) + # Union horizontal runs rather than flood filling every window pixel in + # Python: large opaque windows must retain the bridge's 10 Hz cadence. + parents,runs,previous=[],[],[] + def root(index): + while parents[index]!=index: + parents[index]=parents[parents[index]] + index=parents[index] + return index + for y,row in enumerate(opaque): + changes=np.flatnonzero(np.diff(np.pad(row.astype(np.int8),(1,1)))) + current=[] + cursor=0 + for x0,x1 in zip(changes[::2],changes[1::2]): + index=len(parents) + parents.append(index) + current.append((x0,x1,index)) + runs.append((y,x0,x1,index)) + while cursor=100 and b[3]-b[1]>=80] + if not candidates: + result[:,:,3]=0 + return result + frame=max(candidates,key=lambda box:box[4]) + # SELL's drop target and the original item information windows are + # separate native components. Keep their whole frames as well. Native + # crew/weapon HUD widgets are narrower or below the window area. + windows=[frame]+[b for b in bounds.values() if 80<=b[1]<600 and b[3]<=720 and + b[2]-b[0]>=120 and b[3]-b[1]>=60 and b[4]>=3000 and + not (b[0]>=875 and b[1]<100 and b[3]-b[1]>300)] + keep=np.zeros(opaque.shape,dtype=bool) + for x0,y0,x1,y1,_ in windows:keep[y0:y1,x0:x1]=True + # Fill only holes enclosed by the native bright window contours, rather + # than the entire bounding rectangle. The open gap below SELL/crew windows + # contains the dim native PAUSED glyph; it belongs to the background. + contour=np.zeros_like(result) + contour[(opaque & keep),:3]=255 + result[:,:,3]=clear_world_alpha(contour)[:,:,3] + return result + + +def translate_command(command, state): + modifiers=command.get('data',{}).get('modifiers',{}) + if not isinstance(modifiers,dict) or any(k not in ('shift','control') for k in modifiers): + raise ValueError('Unsupported modifier') + result=_translate_command(command,state) + for instruction in result: + instruction['modifiers']={key:False if command['action']=='system_power' else bool(modifiers.get(key,False)) + for key in ('shift','control')} + return result + + +def _translate_command(command, state): + action, data = command['action'], command.get('data', {}) + if action=='text_input': + entry=state.get('text_entry') or {} + if not entry.get('active') or not data.get('entry_id') or str(data['entry_id'])!=str(entry.get('id')): + raise ValueError('Native rename input is no longer active') + operation=data.get('op') + if operation=='insert': + value=data.get('text','') + if not isinstance(value,str) or not 1<=len(value)<=32 or any(not c.isprintable() for c in value): + raise ValueError('Invalid rename text') + return [{'type':'text','codepoint':ord(character),'entry_id':entry.get('id')} for character in value] + if operation=='cancel': + return [{'type':'text_cancel','entry_id':entry.get('id')}] + events={'confirm':0,'clear':2,'backspace':3,'delete':4,'left':5,'right':6,'home':7,'end':8} + if operation not in events: raise ValueError('Unsupported text operation') + return [{'type':'text_event','event':events[operation],'entry_id':entry.get('id')}] + if action == 'event_choice': + dialog=state.get('dialog') or {} + index=int(data['index']) + choices=dialog.get('choices',[]) + if not state.get('event_open') or str(data.get('dialog_id')) != str(dialog.get('id')): + raise ValueError('Event dialog changed before selection') + if not 0 <= index < min(8,len(choices)) or not choices[index].get('enabled',True): + raise ValueError('Event choice unavailable') + return [{'type':'key','key':49+index}] + if action=='navigate' and data.get('screen') in ('menu','tactical'): + return [{'type':'key','key':27 if data['screen']=='menu' else 289}] + if action in ('ui_mouse', 'ui_move'): + x, y = int(data['x']), int(data['y']) + if not (0 <= x < 1280 and 0 <= y < 720): + raise ValueError('UI coordinates outside the original game canvas') + return [{'type': 'move' if action == 'ui_move' else 'mouse', 'x': x, 'y': y, + 'button': data.get('button', 'left'), 'phase': data.get('phase', 'click')}] + if action == 'key': + allowed = {13,27,32,47,97,98,99,100,101,102,103,104,105,106,107,108, + 109,110,112,113,114,115,116,117,118,119,120,121,122, + 48,49,50,51,52,53,54,55,56,57,289,290,291} + key = int(data['key']) + if key not in allowed: + raise ValueError('Unsupported game hotkey') + return [{'type': 'key', 'key': key}] + if action == 'cancel': + if not world_gameplay_available(state): return [{'type':'key','key':27}] + return [{'type':'mouse','button':'right','phase':'click','x':640,'y':719}, + {'type':'mouse','button':'left','phase':'up','x':640,'y':719}, + {'type':'mouse','button':'left','phase':'click','x':640,'y':719}] + if not world_gameplay_available(state): + raise ValueError('World command requires active gameplay') + if action == 'pause': + return [] if bool(data['paused']) == bool(state['paused']) else [{'type': 'key', 'key': 32}] + if action == 'move_crew' or action == 'select_crew': + crew = next((c for side in ('player','enemy') for c in (state.get(side) or {}).get('crew',[]) + if str(c['id']) == str(data['crew_id'])), None) + if not crew or not crew['controllable'] or crew['ship'] not in (0,1): + raise ValueError('Crew is not controllable on a current ship') + side='player' if crew['ship']==0 else 'enemy' + origin = state[side+'_origin'] + commands = [{'type':'mouse','button':'left','phase':'click', + 'x':round(origin['x']+crew['x']), 'y':round(origin['y']+crew['y'])}] + if action == 'move_crew': + if crew.get('selected'): commands=[] + x,y = room_pixel(state, side, data['room_id']) + commands.append({'type':'mouse','button':'right','phase':'click','x':x,'y':y}) + return commands + if action == 'target_room': + target=state.get('targeting') or {} + active=target.get('active',int(state.get('weapon_selected',-1))>=0) + side=data.get('ship','enemy') + ships=target.get('ships',['enemy']) + if (side=='enemy' and not state.get('combat')) or not active or side not in ships: + raise ValueError('Select an actual FTL weapon or targeted system before targeting this ship') + x,y = room_pixel(state, side, data['room_id']) + return [{'type':'mouse','button':'left','phase':data.get('phase','click'),'x':x,'y':y}] + if action == 'system_power': + system_id=data.get('system_id') + system_key=data.get('system_key') + direction=data.get('direction') + if direction not in (-1,1): raise ValueError('System power changes must be one native click') + rows=(state.get('player') or {}).get('system_status',[]) + row=next((r for r in rows if (system_id is not None and r.get('id')==int(system_id)) or + (system_id is None and system_key and r.get('key')==system_key)),None) + if not row or not row.get('powerable') or not row.get('power_button'): + raise ValueError('System power control is unavailable in the native game') + point=row['power_button'] + return [{'type':'mouse','button':'left' if direction>0 else 'right','phase':'click', + 'x':round(point['x']),'y':round(point['y'])}] + if action == 'door_toggle': + if data.get('ship','player') != 'player': + raise ValueError('Only doors on the player ship are controllable') + door=next((d for d in (state.get('player') or {}).get('doors',[]) + if int(d['id'])==int(data['door_id'])),None) + if not door or not door.get('controllable',False): + raise ValueError('Door is unavailable or locked in the native game') + origin=state['player_origin'] + return [{'type':'mouse','button':'left','phase':'click', + 'x':round(origin['x']+door['x']),'y':round(origin['y']+door['y'])}] + if action == 'navigate': + screen = data['screen'] + if screen in ('jump','ship','store') and not state.get('navigation',{}).get(screen,False): + raise ValueError('Navigation action is unavailable in the current game state') + if screen == 'tactical': + return [{'type':'key','key':289}] + if screen == 'menu': + return [{'type':'key','key':27}] + if screen == 'jump': + point = state['jump_button'] + return [{'type':'mouse','button':'left','x':int(point['x'])+25,'y':int(point['y'])+15}] + if screen == 'ship': + point = state['ship_button'] + return [{'type':'mouse','button':'left','x':int(point['x'])+15,'y':int(point['y'])+15}] + if screen == 'store': + return [{'type':'key','key':291}] + raise ValueError(f'Unsupported command: {action}') + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--lab', type=Path, required=True) + parser.add_argument('--project', type=Path, default=Path(__file__).resolve().parents[1]) + parser.add_argument('--hooks', type=Path, required=True) + parser.add_argument('--seconds', type=float, default=0, help='Bounded diagnostic run; zero keeps bridge running') + parser.add_argument('--pid', type=int, help='Attach only to this verified lab PID instead of launching') + parser.add_argument('--allow-input', action='store_true', help='Enable commands from the VR client') + parser.add_argument('--stop-file',type=Path,help='Stop when this launcher-owned file appears') + args = parser.parse_args() + lab = args.lab.resolve() + marker = json.loads((lab / 'ftlvr-lab.json').read_text()) + if not marker.get('activated'): + raise ValueError('Lab is prepared but not activated') + hooks = json.loads(args.hooks.read_text()) + executable = lab / 'FTLGame.exe' + if hashlib.sha1(executable.read_bytes()).hexdigest() != hooks['sha1']: + raise ValueError('Lab executable fingerprint changed; refusing to attach') + local = args.project.resolve() / 'local_game_data' + local.mkdir(exist_ok=True) + status_path = local / 'bridge_status.json' + command_path = local / 'commands.jsonl' + frame_queue = queue.Queue(maxsize=1) + hud_queue = queue.Queue(maxsize=1) + log_queue = queue.Queue() + stop = threading.Event() + current = {} + native_text_entry={'active':False} + native_power_buttons={} + power_buttons_received=0.0 + capture_size = [1280,720] + state_lock = threading.Lock() + errors = [] + assets = Pkg(Path(marker['source_game'])/'ftl.dat') + prepared_ships = set() + recent_events = deque(maxlen=128) + bridge_session = uuid.uuid4().hex + publisher = FramePublisher(local) + (local/'.gdignore').touch() + + def on_message(message, binary): + if message['type'] == 'error': + errors.append(message.get('stack', str(message))) + return + payload = message.get('payload', {}) + if payload.get('type') in ('frame','hud_frame') and binary: + if payload['type']=='frame': + capture_size[:] = [payload.get('native_width',payload['width']), + payload.get('native_height',payload['height'])] + target=hud_queue if payload['type']=='hud_frame' else frame_queue + # Each stream keeps its newest sample. A regular screen cannot + # displace the supplemental HUD captured immediately before it. + try: target.put_nowait((payload,binary)) + except queue.Full: + try: target.get_nowait() + except queue.Empty: pass + try: target.put_nowait((payload,binary)) + except queue.Full: pass + else: + log_queue.put(payload) + + def encode_frames(source_queue): + last_hud_image = None + while not stop.is_set(): + try: payload,binary = source_queue.get(timeout=0.05) + except queue.Empty: continue + try: + image = Image.frombytes('RGBA',(payload['width'],payload['height']),binary).transpose(Image.Transpose.FLIP_TOP_BOTTOM) + captured_at=payload.get('captured_at',time.time()) + with state_lock: + snapshot=dict(current) + # The Tactical stream retains the complete native screen at a + # smaller resolution. Its independent worker never waits for + # HUD flood-fill or PNG compression. + tactical_only=bool(snapshot.get('tactical') and not any(snapshot.get(key) for key in ('map_open','event_open','panel_open','blocking_ui'))) + if payload['type']=='frame' and tactical_only and payload.get('supplemental_hud'): + publisher.publish('screen_frame',image,captured_at) + continue + if image.size != (1280,720): image = image.resize((1280,720),Image.Resampling.NEAREST) + # Preserve the original UI RGB. Make only the cleared black world transparent. + rgb = image.convert('RGB') + import numpy as np + pixels = np.array(image) + clean_pixels=None + panel_pixels=None + if payload['type']=='hud_frame': + if full_screen_capture(snapshot): continue + last_hud_image=Image.fromarray(remove_enemy_hud(clear_world_alpha(pixels))) + publisher.publish('hud_frame',last_hud_image,captured_at) + with state_lock: current['capture_full_screen']=False + continue + controller_screen=bool(snapshot.get('map_open') or snapshot.get('tactical')) + event_open=bool(snapshot.get('event_open')) + panel_open=bool(snapshot.get('panel_open')) + full_screen=full_screen_capture(snapshot) + # Native windows stay original. Gameplay never promotes a colored + # world/jump frame into an opaque head-locked screen. + if controller_screen: + publisher.publish('screen_frame',image,captured_at) + if event_open: + publisher.publish('event_frame',rgb.convert('RGBA'),captured_at) + if panel_open: + panel_pixels=native_window_frame(pixels) + publisher.publish('panel_frame',Image.fromarray(panel_pixels),captured_at) + # This original frame still supplies map/dialog/window pixels. + # Its independent HUD sample is processed by its own worker. + if payload.get('supplemental_hud') and not full_screen: + continue + if full_screen: + image=rgb.convert('RGBA') + elif controller_screen or event_open or panel_open or snapshot.get('transition'): + if last_hud_image is None: + if panel_open: + clean_pixels=clear_world_alpha(pixels) + hud=clean_pixels.copy() + hud[panel_pixels[:,:,3]>0,3]=0 + last_hud_image=Image.fromarray(remove_enemy_hud(hud)) + elif event_open: + pixels=clear_world_alpha(pixels) + left=313 if (snapshot.get('dialog') or {}).get('centered',True) else 163 + pixels[138:528,left:left+650,3]=0 + last_hud_image=Image.fromarray(remove_enemy_hud(pixels)) + else: + last_hud_image=Image.new('RGBA',(1280,720),(0,0,0,0)) + image=last_hud_image.copy() + else: + # A transition can race a snapshot by one frame. Retain the + # last UI until the native world is clipped; never flash it. + middle=np.any(pixels[180:490,400:850,:3]!=0,axis=2) + if float(middle.mean())>0.18: + if last_hud_image is None: continue + image=last_hud_image.copy() + else: + pixels=clear_world_alpha(pixels) + image=Image.fromarray(remove_enemy_hud(pixels)) + last_hud_image=image.copy() + if not full_screen: + image=Image.fromarray(remove_pause_overlay(remove_enemy_hud(np.array(image)))) + with state_lock: + current['capture_full_screen']=full_screen + publisher.publish('hud_frame',image,captured_at) + except Exception as error: + errors.append('Frame encode: '+str(error)) + + workers=[threading.Thread(target=encode_frames,args=(source,),daemon=True) + for source in (frame_queue,hud_queue)] + for worker in workers: worker.start() + process = None + if args.pid is None: + startup = subprocess.STARTUPINFO() + startup.dwFlags |= subprocess.STARTF_USESHOWWINDOW + startup.wShowWindow = 0 + process = subprocess.Popen([str(executable),'--force-opengl'],cwd=lab, + startupinfo=startup,creationflags=subprocess.CREATE_NO_WINDOW) + pid = process.pid + else: + pid = args.pid + # Verify the attached process is this lab executable, not a different FTL copy. + import ctypes + from ctypes import wintypes + kernel = ctypes.WinDLL('kernel32',use_last_error=True) + kernel.OpenProcess.argtypes=[wintypes.DWORD,wintypes.BOOL,wintypes.DWORD] + kernel.OpenProcess.restype=wintypes.HANDLE + kernel.QueryFullProcessImageNameW.argtypes=[wintypes.HANDLE,wintypes.DWORD,wintypes.LPWSTR,ctypes.POINTER(wintypes.DWORD)] + kernel.CloseHandle.argtypes=[wintypes.HANDLE] + handle=kernel.OpenProcess(0x1000,False,pid) + buffer=ctypes.create_unicode_buffer(32768); count=wintypes.DWORD(len(buffer)) + try: + if not handle or not kernel.QueryFullProcessImageNameW(handle,0,buffer,ctypes.byref(count)) or Path(buffer.value).resolve()!=executable: + raise ValueError('PID does not belong to the verified isolated lab') + finally: + if handle: kernel.CloseHandle(handle) + print('FTLVR_LAB_PID',pid,flush=True) + session=None + start=time.monotonic() + command_offset=command_path.stat().st_size if command_path.exists() else 0 + log_offset=0 + native_capture_status={} + capture_status_received=0.0 + supplemental_enabled=None + pending_log='' + try: + time.sleep(1) + session=frida.attach(pid) + agent_source=(Path(__file__).parents[1]/'bridge/agent.js').read_text() + hooks['capture_interval_ms']=100 + script=session.create_script(agent_source.replace('CONFIG_PLACEHOLDER',json.dumps(hooks))) + script.on('message',on_message) + script.load() + if process: + # Install native hooks after Hyperspace has finished its own detours. + boot_deadline = time.monotonic()+45 + while time.monotonic()=1: + native_capture_status=script.exports_sync.status() + capture_status_received=time.monotonic() + atomic_json(status_path,{'pid':pid,'connected':True,'input_enabled':args.allow_input, + 'live_state_received':bool(current) and time.time()-current.get('bridge_time_unix',0)<2, + 'capture_received':any((local/name).exists() and time.time()-(local/name).stat().st_mtime<2 for name in ('hud_frame.rgba','hud_frame.png')), + 'errors':errors[-10:],'heartbeat':time.time(),'native_capture':native_capture_status}) + time.sleep(0.02) + finally: + stop.set() + for worker in workers: worker.join(timeout=2) + publisher.close() + if process and process.poll() is None: + try: + script.exports_sync.command({'type':'key','key':290,'id':'save-and-exit'}) + process.wait(timeout=8) + except Exception: + process.terminate(); process.wait(timeout=10) + errors.append('Graceful exit failed; last FTL autosave remains available') + if session: + try: session.detach() + except frida.InvalidOperationError: pass + atomic_json(status_path,{'pid':pid,'connected':False,'errors':errors[-10:],'heartbeat':time.time()}) + if errors: print('FTLVR_ERRORS',json.dumps(errors[-10:]),flush=True) + + +if __name__=='__main__': + main() diff --git a/tools/setup_environment.py b/tools/setup_environment.py new file mode 100644 index 0000000..ea34976 --- /dev/null +++ b/tools/setup_environment.py @@ -0,0 +1,129 @@ +"""Create this source checkout's Python environment, or check launch readiness. + +Setup installs Python packages and creates local configuration only. It never +copies, patches, launches or attaches to FTL. --check and --dry-run are read-only. +""" + +from __future__ import annotations + +import argparse +import importlib.util +import json +import os +import shutil +import subprocess +import sys +from pathlib import Path + +PROJECT = Path(__file__).resolve().parents[1] +VENV = PROJECT / ".venv" +VENV_PYTHON = VENV / "Scripts" / "python.exe" +LOCAL = PROJECT / "local_game_data" +CONFIG = LOCAL / "launcher.json" + + +def require_platform() -> None: + if os.name != "nt": + raise ValueError("The live game bridge currently supports Windows only.") + if sys.version_info < (3, 10): + raise ValueError("Python 3.10 or newer is required; Python 3.12 is recommended.") + + +def setup(dry_run: bool = False) -> None: + require_platform() + steps = [ + f"Create or reuse {VENV}", + "Install the packages from requirements.txt inside .venv (internet access required)", + "Copy config/launcher.example.json to local_game_data/launcher.json if absent", + "Keep all game paths unconfigured until you edit launcher.json", + ] + for step in steps: + print(step, flush=True) + if dry_run: + print("Dry run complete: no files or dependencies were changed.") + return + if VENV.exists() and not VENV_PYTHON.is_file(): + raise ValueError(".venv exists but is incomplete. Rename that folder and run SETUP.cmd again.") + if not VENV_PYTHON.is_file(): + subprocess.run([sys.executable, "-m", "venv", str(VENV)], check=True) + subprocess.run( + [str(VENV_PYTHON), "-m", "pip", "install", "-r", str(PROJECT / "requirements.txt")], + check=True, + ) + LOCAL.mkdir(exist_ok=True) + (LOCAL / ".gdignore").touch() + if not CONFIG.exists(): + shutil.copyfile(PROJECT / "config" / "launcher.example.json", CONFIG) + print("\nPython environment ready. Finish docs/INSTALLATION.md before launching.") + print(f"Edit your local paths in: {CONFIG}") + print("SETUP.cmd does not install Hyperspace or prepare a game copy.") + + +def check(desktop: bool = False) -> None: + require_platform() + missing = [name for name in ("frida", "PIL", "numpy", "capstone") + if importlib.util.find_spec(name) is None] + if missing: + raise ValueError("Missing Python packages: " + ", ".join(missing) + ". Run SETUP.cmd first.") + if not CONFIG.is_file(): + raise ValueError("Missing local_game_data/launcher.json. Run SETUP.cmd and edit its paths.") + config = json.loads(CONFIG.read_text(encoding="utf-8-sig")) + if not isinstance(config, dict): + raise ValueError("launcher.json must contain a JSON object with lab, hooks and godot paths.") + for key in ("lab", "hooks", "godot"): + if not isinstance(config.get(key), str) or not config[key].strip(): + raise ValueError(f"launcher.json needs a nonempty {key} path.") + if not Path(config[key]).is_absolute(): + raise ValueError(f"Use an absolute path for {key} in launcher.json.") + lab = Path(config["lab"]) + if not lab.is_dir(): + raise ValueError("The configured lab directory does not exist. Complete isolated lab preparation.") + for key in ("hooks", "godot"): + if not Path(config[key]).is_file(): + raise ValueError(f"The configured {key} file does not exist: {config[key]}") + if not (lab / "ftlvr-lab.json").is_file(): + raise ValueError("The configured lab has no ftlvr-lab.json marker. Complete isolated lab preparation.") + marker = json.loads((lab / "ftlvr-lab.json").read_text(encoding="utf-8-sig")) + if not isinstance(marker, dict): + raise ValueError("The lab marker is malformed. Use a correctly prepared isolated lab.") + source = marker.get("source_game") + if not isinstance(source, str) or not (Path(source) / "ftl.dat").is_file(): + raise ValueError("The owned original installation recorded by the lab is unavailable. Restore that path.") + if not (LOCAL / "manifest.json").is_file(): + raise ValueError("Local game assets are missing. Run tools/extract_ftl.py on your owned ftl.dat.") + manifest = json.loads((LOCAL / "manifest.json").read_text(encoding="utf-8-sig")) + if not isinstance(manifest, dict): + raise ValueError("The local asset manifest is malformed. Run tools/extract_ftl.py again.") + if not manifest.get("ships") or not manifest.get("fonts") or not manifest.get("ui_assets"): + raise ValueError("Local extraction is incomplete. Run tools/extract_ftl.py again.") + # The production launcher's own checks include executable fingerprints, + # required hook families, dependencies, loader and the OpenXR runtime. + sys.dont_write_bytecode = True + from launch import preflight + result = preflight(config, desktop=desktop) + print(json.dumps(result, indent=2)) + print("Setup checks passed. No game was launched or modified.") + print("This checks readiness; it does not measure headset performance.") + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + modes = parser.add_mutually_exclusive_group() + modes.add_argument("--check", action="store_true", help="Read-only local launch preflight") + modes.add_argument("--dry-run", action="store_true", help="Describe setup without writing or installing") + parser.add_argument("--desktop", action="store_true", help="Skip the SteamVR runtime check with --check") + args = parser.parse_args() + if args.desktop and not args.check: + parser.error("--desktop is only valid with --check") + if args.check: + check(desktop=args.desktop) + else: + setup(dry_run=args.dry_run) + + +if __name__ == "__main__": + try: + main() + except (OSError, ValueError, KeyError, subprocess.CalledProcessError) as error: + print(f"FTL Tabletop VR setup: {error}", file=sys.stderr) + sys.exit(1) diff --git a/tools/test_bridge.py b/tools/test_bridge.py new file mode 100644 index 0000000..deefeb3 --- /dev/null +++ b/tools/test_bridge.py @@ -0,0 +1,271 @@ +import json +import tempfile +import time +import unittest +from pathlib import Path +from PIL import Image + +import numpy as np +from hud_alpha import clear_world_alpha +from run_bridge import (decode_state_line, translate_command, full_screen_capture, + remove_enemy_hud, native_window_frame, remove_pause_overlay, + FRAME_HEADER, raw_frame_bytes, FramePublisher) + + +class BridgeTests(unittest.TestCase): + def test_raw_transport_keeps_dimensions_rgba_orientation_and_timestamp(self): + image=Image.new('RGBA',(960,540),(10,20,30,40)) + image.putpixel((0,0),(1,2,3,4));image.putpixel((0,539),(5,6,7,8)) + raw=raw_frame_bytes(image,12,123.5) + self.assertEqual(FRAME_HEADER.size,36) + self.assertEqual(FRAME_HEADER.unpack(raw[:36]),(b'FVR1',1,960,540,4,12,123.5)) + self.assertEqual(len(raw),36+960*540*4) + self.assertEqual(raw[36:40],bytes((1,2,3,4))) + self.assertEqual(raw[36+539*960*4:40+539*960*4],bytes((5,6,7,8))) + + def test_independent_raw_streams_publish_without_waiting_for_png_preview(self): + with tempfile.TemporaryDirectory() as directory: + publisher=FramePublisher(Path(directory)) + image=Image.new('RGBA',(16,9),(10,20,30,40)) + try: + for i in range(8):publisher.publish('screen_frame',image,float(i)) + publisher.publish('hud_frame',image,8.) + raw=(Path(directory)/'screen_frame.rgba').read_bytes() + self.assertEqual(FRAME_HEADER.unpack(raw[:36])[-2:],(8,7.)) + self.assertTrue((Path(directory)/'hud_frame.rgba').exists()) + deadline=time.monotonic()+1 + while not (Path(directory)/'hud_frame.png').exists() and time.monotonic() void: + call_deferred("_run") + + +func _check(condition: bool, message: String) -> void: + if not condition: + failures += 1 + push_error(message) + + +func _run() -> void: + var scene: Node3D = load("res://main.tscn").instantiate() + root.add_child(scene) + await process_frame + scene.set_process(false) + var player: Node3D = scene.player_ship + var enemy: Node3D = scene.enemy_ship + var effects: Node3D = scene.combat_effects + player.set_process(false) + enemy.set_process(false) + effects.set_process(false) + _check(player.global_basis.x.normalized().dot(Vector3.RIGHT) > 0.99, "Player bow must face enemy") + _check((-enemy.global_basis.z).normalized().dot(Vector3.LEFT) > 0.99, "Enemy bow must face player") + _check(scene.hud_surface.get_parent() == scene.camera, "HUD must follow the tracked headset") + for ship in [player, enemy]: + _check(ship.weapon_origin(0).is_equal_approx(ship.to_global(ship.mount_points[0])), "Mount must use the ship world transform") + _check(ship.room_target(0).is_equal_approx(ship.to_global(ship.room_points[0])), "Target room must use the ship world transform") + ship.set_shields(0) + _check(not ship.shield_shell.visible, "Empty shields must disappear") + ship.set_shields(0, 5) + _check(ship.shield_shell.visible, "Super shields must remain visible") + ship.set_shields(2) + var from: Vector3 = player.weapon_origin(0) + var target: Vector3 = enemy.room_target(0) + var hit: Vector3 = enemy.shield_intercept(from, target) + var normalized: Vector3 = (enemy.to_local(hit) - enemy.shield_center) / enemy.shield_radii + _check(absf(normalized.length() - 1.0) < 0.001, "Shield hit must lie on the rotated ellipsoid") + _check(from.distance_to(hit) < from.distance_to(target), "Shield must intercept before the target room") + for source in ["player", "enemy"]: + var sender: Node3D = player if source == "player" else enemy + var receiver: Node3D = enemy if source == "player" else player + scene._present_shot({"source": source, "kind": "laser", "target_room": 0, "outcome": "hull", "duration": 1.0}) + var shot: Dictionary = effects.shots.back() + _check(Vector3(shot["start"]).is_equal_approx(sender.weapon_origin(0)), "Shot must originate at sender weapon") + _check(Vector3(shot["end"]).is_equal_approx(receiver.room_target(0)), "Shot must terminate at receiver room") + var node: MeshInstance3D = shot["node"] + effects._process(0.5) + _check(node.global_position.is_equal_approx(Vector3(shot["start"]).lerp(shot["end"], 0.5)), "Projectile must travel between ships") + effects.simulation_paused = true + var paused_position := node.global_position + effects._process(0.4) + _check(node.global_position.is_equal_approx(paused_position), "Pause must freeze projectiles") + effects.simulation_paused = false + effects._process(0.6) + _check(effects.shots.is_empty(), "Finished projectile must be removed") + _check(not effects.impacts.is_empty(), "Hull hit must produce an impact") + effects.simulation_paused = true + var flash: MeshInstance3D = effects.impacts.back()["node"] + var flash_scale := flash.scale + effects._process(0.4) + _check(flash.scale == flash_scale, "Pause must freeze impact flashes") + effects.simulation_paused = false + effects._process(0.3) + var impact_count: int = effects.impacts.size() + scene._present_shot({"source": "player", "kind": "laser", "outcome": "miss", "duration": 0.1}) + effects._process(0.2) + _check(effects.impacts.size() == impact_count, "Miss must not produce a hull impact") + scene._present_shot({"source": "enemy", "kind": "bomb", "outcome": "hull", "duration": 1.0}) + _check(not effects.shots.back()["node"].visible, "Bomb must teleport rather than fly through space") + effects._process(1.1) + scene._present_shot({"source": "player", "kind": "beam", "outcome": "shield", "target_room": 0, "end_room": 4, "duration": 1.0}) + effects._process(0.5) + var beam: Dictionary = effects.shots.back() + var mesh: ImmediateMesh = beam["node"].mesh + var vertices: PackedVector3Array = mesh.surface_get_arrays(0)[Mesh.ARRAY_VERTEX] + _check(vertices[0].is_equal_approx(effects.to_local(player.weapon_origin(0))), "Beam must connect to sender mount") + var core: MeshInstance3D = beam["node"].get_node("BeamCore") + var swept_endpoint: Vector3 = Vector3(beam["end"]).lerp(beam["beam_end"], 0.5) + _check(core.global_position.is_equal_approx((player.weapon_origin(0) + swept_endpoint) * 0.5), "Visible beam cylinder must follow its exact native sweep line") + _check(absf(core.global_basis.y.length() - player.weapon_origin(0).distance_to(swept_endpoint)) < 0.001, "Beam transform must preserve its emitter and target positions") + _check(core.mesh == beam["node"].get_node("BeamGlow").mesh and core.mesh.height == 1.0, "Beam layers must reuse immutable unit geometry") + var mesh_changes := {"count": 0} + core.mesh.changed.connect(func(): mesh_changes["count"] += 1) + effects._process(0.1) + _check(mesh_changes["count"] == 0, "A live sweep must not rebuild shared beam geometry") + var endpoint: Vector3 = beam["beam_end"] + _check(absf(((enemy.to_local(endpoint) - enemy.shield_center) / enemy.shield_radii).length() - 1.0) < 0.001, "Blocked beam must stay on shield") + effects._process(0.6) + _check(enemy.shield_flash > 0.0, "Shield impact must flash") + effects._process(0.3) + scene._present_shot({"source": "player", "kind": "laser", "outcome": "pending", "projectile_id": "live-miss", "target_room": 0}) + effects.update_live_projectiles([{"id": "live-miss", "progress": 0.7, "missed": true}]) + effects._process(0.1) + _check(Vector3(effects.shots.back()["end"]).distance_to(enemy.room_target(0)) > 0.4, "Native miss trajectory must survive endpoint updates") + effects.update_live_projectiles([]) + effects._process(0.3) + effects.update_live_projectiles([]) + scene._present_shot({"source": "player", "kind": "beam", "outcome": "pending", "projectile_id": "live-beam", "target_room": 0, "end_point": {"x": 100, "y": 180}}) + var beam_point := {"x": 80, "y": 170} + effects.update_live_projectiles([{"id": "live-beam", "progress": 0.65, "beam_point": beam_point}]) + effects._process(0.1) + var live_beam: Dictionary = effects.shots.back() + var live_mesh: ImmediateMesh = live_beam["node"].mesh + var live_vertices: PackedVector3Array = live_mesh.surface_get_arrays(0)[Mesh.ARRAY_VERTEX] + _check(live_vertices[1].is_equal_approx(effects.to_local(enemy.to_global(enemy.pixel_point(beam_point)))), "Live beam must follow FTL's current sweep position") + effects.resolve_live({"projectile_id": "live-beam", "outcome": "beam", "target": beam_point}, enemy) + _check(effects.shots.size() == 1, "First beam room hit must not delete a continuing sweep") + effects.update_live_projectiles([]) + effects._process(0.4) + effects.update_live_projectiles([]) + for kind in ["laser", "missile", "ion", "asteroid"]: + effects.spawn_shot({"source": "player", "kind": kind, "outcome": "hull", "duration": 1.0, "target_room": 0}, player, enemy) + var refined: Dictionary = effects.shots.back() + effects._process(0.5) + var refined_node: MeshInstance3D = refined["node"] + var direction: Vector3 = Vector3(refined["end"]) - Vector3(refined["start"]) + _check((-refined_node.global_basis.z).normalized().dot(direction.normalized()) > 0.999, "Projectile model must point from sender toward receiver") + _check(refined_node.global_position.is_equal_approx(Vector3(refined["start"]).lerp(refined["end"], 0.5)), "Refined geometry must preserve authoritative travel timing") + effects._process(0.6) + # A combat drone can be owned by the player while physically orbiting the + # enemy. A defense drone can instead target a missile in its own space. + var drone_origin := {"x": 90, "y": 130} + var drone_target := {"x": 170, "y": 120} + effects.spawn_shot({"kind": "laser", "outcome": "pending", "projectile_id": "defense", + "origin_point": drone_origin, "origin_space": 1, "target": drone_target, + "target_space": 1}, player, enemy) + effects.update_live_projectiles([{"id": "defense", "progress": 0.4}]) + effects._process(0.1) + var defense: Dictionary = effects.shots.back() + _check(Vector3(defense["start"]).is_equal_approx(enemy.to_global(enemy.pixel_point(drone_origin))), "Boarding drone fire must originate in its current native space") + _check(Vector3(defense["end"]).is_equal_approx(enemy.to_global(enemy.pixel_point(drone_target))), "Defense drone fire must target the actual native space and point") + # A paused native projectile remains frozen even beyond the presentation + # grace. A stale fire record must expire despite simulation time standing still. + effects.simulation_paused = true + var paused_native_node: MeshInstance3D = defense["node"] + var paused_native_position := paused_native_node.global_position + var paused_native_time: float = defense["time"] + var paused_impact_count: int = effects.impacts.size() + effects.spawn_shot({"kind": "laser", "outcome": "pending", "projectile_id": "paused-phantom"}, player, enemy) + effects.update_live_projectiles([{"id": "defense", "progress": 0.4}]) + _check(effects.shots.size() == 2, "A newly received fire record must retain a short snapshot grace") + var grace_start_ms := Time.get_ticks_msec() + while Time.get_ticks_msec() - grace_start_ms < 360: + await process_frame + effects._process(1.0) + effects.update_live_projectiles([{"id": "defense", "progress": 0.4}]) + _check(effects.shots.size() == 1 and effects.shots[0]["projectile_id"] == "defense", "Paused never-observed fire must expire using monotonic presentation time") + _check(paused_native_node.global_position == paused_native_position and defense["time"] == paused_native_time, "A genuinely active paused projectile must remain frozen beyond the grace") + effects.update_live_projectiles([]) + _check(effects.shots.is_empty(), "A previously observed projectile must disappear immediately when native state removes it during pause") + _check(effects.impacts.size() == paused_impact_count, "Stale projectile cleanup must not invent a hit") + effects.spawn_shot({"kind": "beam", "outcome": "pending", "projectile_id": "paused-beam"}, player, enemy) + effects.update_live_projectiles([{"id": "paused-beam", "progress": 0.4}]) + effects.resolve_live({"projectile_id": "paused-beam", "outcome": "beam", "target": beam_point}, enemy) + _check(effects.shots.size() == 1, "A paused beam tile hit must retain the native continuing sweep") + print("COMBAT_TESTS ", "PASS" if failures == 0 else "FAIL: %d" % failures) + scene.queue_free() + await process_frame + quit(0 if failures == 0 else 1) diff --git a/tools/test_combat.gd.uid b/tools/test_combat.gd.uid new file mode 100644 index 0000000..5f534a9 --- /dev/null +++ b/tools/test_combat.gd.uid @@ -0,0 +1 @@ +uid://dtdhv1evsbxrk diff --git a/tools/test_controller_ui.gd b/tools/test_controller_ui.gd new file mode 100644 index 0000000..a39772d --- /dev/null +++ b/tools/test_controller_ui.gd @@ -0,0 +1,307 @@ +extends SceneTree + +var failures := 0 + + +func _initialize() -> void: + call_deferred("run") + + +func check(condition: bool, message: String) -> void: + if not condition: + failures += 1 + push_error(message) + + +func rendered() -> void: + for i in range(3): await process_frame + await RenderingServer.frame_post_draw + + +func pixel_world(panel: Node3D, pixel: Vector2) -> Vector3: + return panel.to_global(Vector3((pixel.x / 1150.0 - .5) * .92,(.5 - pixel.y / 900.0) * .72,.012)) + + +func run() -> void: + _verify_bindings() + _verify_native_fonts() + var camera := Camera3D.new() + camera.position = Vector3(0,0,3) + camera.current = true + root.add_child(camera) + var panel: Node3D = load("res://scripts/controller_panel.gd").new() + root.add_child(panel) + var menu := [{"label":"JUMP","position":Vector2(-.22,.235)}, {"label":"SHIP","position":Vector2(.22,.235)}, {"label":"SHORTCUTS","position":Vector2(-.22,.155)}, {"label":"TACTICAL VIEW","position":Vector2(.22,.155)}, {"label":"CONTROLS / HELP","position":Vector2(-.22,.075)}, {"label":"RECENTER","position":Vector2(.22,.075)}] + panel.set_navigation("NAVIGATION",menu,true) + var revision: int = panel.content_revision + panel.set_navigation("NAVIGATION",menu.duplicate(true),true) + check(panel.content_revision == revision,"An unchanged controller page must not schedule another viewport render") + check(panel.viewport.render_target_update_mode == SubViewport.UPDATE_ONCE,"The themed navigation panel must render on demand") + await rendered() + panel.viewport.get_texture().get_image().save_png("res://local_game_data/controller-navigation-theme.png") + var player := {"system_status":[ + {"key":"shields","id":0,"allocated":3,"max_power":4,"damaged":1,"ionized":0,"powerable":true}, + {"key":"engines","id":1,"allocated":2,"max_power":5,"damaged":0,"ionized":0,"powerable":true}, + {"key":"weapons","id":3,"allocated":3,"max_power":4,"damaged":0,"ionized":0,"powerable":true}, + {"key":"oxygen","id":2,"allocated":1,"max_power":1,"damaged":0,"ionized":0,"powerable":true}, + {"key":"mind","id":14,"allocated":0,"max_power":3,"damaged":0,"ionized":2,"powerable":true}, + {"key":"doors","id":8,"allocated":0,"max_power":3,"damaged":0,"ionized":0,"powerable":false}], + "reactor":{"available":7,"total":16,"used":9,"battery_available":0,"battery_total":0}} + panel.set_power(player) + check(panel.power_rows.size() == 5 and panel.selected_system().id == 0,"The power page must display installed powerable systems and preserve native system IDs") + revision = panel.content_revision + player.crew = [{"name":"Moving crew"}] + panel.set_power(player) + check(panel.content_revision == revision,"Unrelated crew movement must not redraw the system power page") + panel.step_selection(-1) + check(panel.selected_system().key == "mind","X must wrap to the previous installed powerable system") + panel.step_selection(1) + check(panel.selected_system().key == "shields","Y must wrap back to the next system") + panel.select_system("weapons") + check(panel.selected_system().allocated == 3 and panel.selected_system().max_power == 4,"Selected system power values must come from the native live schema") + panel.select_system("missing") + check(panel.selected_system().key == "weapons","A missing system must not replace the valid selection") + panel.global_transform = Transform3D(Basis.from_euler(Vector3(-.5,.3,.1)).scaled(Vector3.ONE * .6),Vector3(.4,.9,-.7)) + var rect: Rect2 = panel.power_card_rect(4) + var point := pixel_world(panel,rect.get_center()) + var hit: Dictionary = panel.ray_hit(point + panel.global_basis.z.normalized(),-panel.global_basis.z.normalized()) + check(hit.get("system_key","") == "mind" and hit.position.distance_to(point) < .0001,"Rotated and scaled power cards must be pointable without native frame pixels or physics collisions") + hit = panel.ray_hit(panel.to_global(Vector3(0,.345,1)),-panel.global_basis.z.normalized()) + check(not hit.is_empty() and hit.system_key == "","Blank panel/header areas must block the world without changing selection") + panel.visible = false + check(panel.ray_hit(point + panel.global_basis.z.normalized(),-panel.global_basis.z.normalized()).is_empty(),"A hidden power page must not intercept the controller ray") + panel.visible = true + panel.global_transform = Transform3D.IDENTITY + await rendered() + var power_image: Image = panel.viewport.get_texture().get_image() + power_image.save_png("res://local_game_data/controller-system-power-theme.png") + # Changing power schedules exactly one real render and changes GPU pixels. + var before: Color = power_image.get_pixel(276,217) + player.system_status[0].allocated = 0 + revision = panel.content_revision + panel.set_power(player) + check(panel.content_revision == revision + 1,"A live allocation change must request one power-page render") + await rendered() + check(panel.viewport.get_texture().get_image().get_pixel(276,217) != before,"Native power changes must update the visible GPU power bars") + # Native updates may reorder rows; retain stable key selection. + player.system_status.reverse() + panel.set_power(player) + check(panel.selected_system().key == "weapons","Native row reordering must preserve the selected system") + # Actual native QA: a Zoltan in Oxygen gives allocated=0, bonus=1, + # effective=1. Battery is separate from allocated in GetEffectivePower. + var source_player := {"system_status":[ + {"key":"oxygen","id":2,"allocated":0,"bonus_power":1,"battery_power":0,"effective_power":1,"max_power":1,"power_cap":1,"powerable":true}, + {"key":"weapons","id":3,"allocated":1,"bonus_power":1,"battery_power":2,"effective_power":4,"max_power":5,"damaged":1,"power_cap":4,"powerable":true}, + {"key":"engines","id":1,"allocated":1,"effective_power":1,"max_power":3,"power_cap":2,"ionized":true,"locked":true,"powerable":true}, + {"key":"hacking","id":15,"allocated":1,"effective_power":1,"max_power":2,"power_cap":2,"hacked":2,"powerable":true}], + "reactor":{"available":7,"total":16,"used":9,"battery_available":0,"battery_total":2}} + panel.set_power(source_player) + panel.select_system("oxygen") + await rendered() + var source_image: Image = panel.viewport.get_texture().get_image() + source_image.save_png("res://local_game_data/controller-system-power-sources.png") + check(source_image.get_pixel(269,213).to_html(false) == "fffa5a","A zero-reactor Oxygen system powered by a Zoltan must render a native yellow bonus pip") + check(panel.selected_system().allocated == 0 and panel.selected_system().bonus_power == 1 and panel.selected_system().effective_power == 1,"Drawing effective power must preserve raw native selected-row power fields") + check(panel.reactor.available == 7 and panel.reactor.used == 9,"Zoltan and battery display must not debit, recompute or double count native reactor availability") + check(source_image.get_pixel(269,319).to_html(false) == "7fff74","Reactor-supplied system power must retain its green pip") + check(source_image.get_pixel(294,319).to_html(false) == "7fff74" and source_image.get_pixel(282,318).to_html(false) == "e66e1e","Battery pips must have the native green inner pip and distinct orange outline") + check(source_image.get_pixel(344,319).to_html(false) == "fffa5a","Bonus power must follow allocated and battery pips, without counting battery twice") + check(source_image.get_pixel(369,319).to_html(false) == "ed6260","Damaged capacity must remain red after powered-source pips") + check(source_image.get_pixel(719,213).to_html(false) == "85e7ed","Ion-locked allocated power must use the native cyan status color") + check(source_image.get_pixel(757,212).to_html(false) == "85e7ed","Power capped cells must retain a cyan outline rather than appear available") + check(source_image.get_pixel(719,319).to_html(false) == "cf46fd","Hacked allocated power must use the native purple status color") + # The effective-power numerator is authoritative even when raw allocated is 0. + # Compare its rendered numeric region against the otherwise identical 0/1 case. + var powered_count: Image = source_image.get_region(Rect2i(537,206,50,15)) + source_player.system_status[0].effective_power = 0 + source_player.system_status[0].bonus_power = 0 + panel.set_power(source_player) + await rendered() + check(powered_count.get_data() != panel.viewport.get_texture().get_image().get_region(Rect2i(537,206,50,15)).get_data(),"The visible power count must show native effective 1/1 instead of raw allocated 0/1") + var wheel: Node3D = load("res://scripts/shortcut_wheel.gd").new() + root.add_child(wheel) + wheel.set_entries([{"label":"BURST LASER II","enabled":true},{"label":"ARTEMIS","enabled":true},{"label":"EMPTY","enabled":false},{"label":"EMPTY","enabled":false},{"label":"COMBAT DRONE","enabled":true},{"label":"BEAM DRONE","enabled":true}],"WEAPONS\nDRONES") + wheel.choose(Vector2(0,1)) + revision = wheel.content_revision + wheel.choose(Vector2(0,.9)) + check(wheel.selected == 0 and wheel.content_revision == revision,"A stable wheel sector must not rerender every joystick sample") + wheel.choose(Vector2(1,0)) + check(wheel.selected == 2,"The themed wheel must retain clockwise 1-8 native slot ordering") + wheel.choose(Vector2.ZERO) + check(wheel.selected == -1,"The wheel center must continue to cancel") + wheel.choose(Vector2(0,1)) + wheel.visible = true + await rendered() + wheel.viewport.get_texture().get_image().save_png("res://local_game_data/controller-action-wheel-theme.png") + var help: Node3D = load("res://scripts/controller_help.gd").new() + root.add_child(help) + await rendered() + help.viewport.get_texture().get_image().save_png("res://local_game_data/controller-help-theme.png") + check(help.canvas.font == load("res://scripts/ftl_ui_assets.gd").font("body"),"Help callouts must use the actual vanilla UI font") + var keyboard: Node3D = load("res://scripts/rename_keyboard.gd").new() + root.add_child(keyboard) + keyboard.set_entry({"active":true,"id":"crew1","kind":"CREW","text":"Elisabeth","cursor":9}) + check(keyboard.title.get_theme_font("font") == load("res://scripts/ftl_ui_assets.gd").font("header") and keyboard.value_label.get_theme_font("font") == load("res://scripts/ftl_ui_assets.gd").font("body"),"Rename keyboard and name entry must use actual native fonts") + var q_point := Vector3((74.0 / 1000.0 - .5) * .86,(.5 - 188.0 / 454.0) * .39,0) + var q_hit: Dictionary = keyboard.ray_hit(q_point + Vector3(0,0,1),Vector3(0,0,-1)) + check(q_hit.get("text","") == "Q" and keyboard.activate(q_hit).get("op","") == "insert","Restyling the keyboard must preserve QWERTY key picking and scoped text operations") + await rendered() + keyboard.viewport.get_texture().get_image().save_png("res://local_game_data/controller-keyboard-theme.png") + var hull_bar: Node3D = load("res://scripts/enemy_hull_bar.gd").new() + root.add_child(hull_bar) + hull_bar.build() + hull_bar.set_state({"hull":30,"hull_max":30,"name":"REBEL FIGHTER"}) + await rendered() + var full_hull: Image = hull_bar.viewport.get_texture().get_image() + check(full_hull.get_pixel(690,74).g > .9 and full_hull.get_pixel(690,74).r < .6,"Full enemy hull must use the actual vanilla segmented-mask fill") + hull_bar.set_state({"hull":18,"hull_max":30,"name":"REBEL FIGHTER"}) + await rendered() + var partial_hull: Image = hull_bar.viewport.get_texture().get_image() + partial_hull.save_png("res://local_game_data/controller-enemy-hull-theme.png") + check(partial_hull.get_pixel(690,74).g < .5 and partial_hull.get_pixel(120,74).g > .9,"Native enemy hull changes must shorten the visible health fill and preserve remaining hull cells") + check(partial_hull.get_pixel(400,8).a == 0,"The enemy hull display must retain transparent space around the native contour without a generic banner") + check(load("res://scripts/ftl_ui_assets.gd").texture("img/statusUI/top_hull.png") != null,"The hull contour must come from owner-local vanilla art") + hull_bar.set_state({}) + check(not hull_bar.visible,"Enemy hull display must disappear when native enemy state is absent") + await _verify_world_font_render() + panel.queue_free() + wheel.queue_free() + help.queue_free() + keyboard.queue_free() + hull_bar.queue_free() + await process_frame + print("CONTROLLER_UI_TESTS ","PASS" if failures == 0 else "FAIL %d" % failures) + quit(0 if failures == 0 else 1) + + +func _verify_bindings() -> void: + var path := "res://openxr_action_map.tres" + for argument in OS.get_cmdline_user_args(): + if argument.begins_with("--action-map="): path = argument.trim_prefix("--action-map=") + var map: OpenXRActionMap = load(path) + check(map != null,"The controller action map must parse as an OpenXRActionMap") + if map == null: return + var profile: OpenXRInteractionProfile = map.find_interaction_profile("/interaction_profiles/valve/frame_controller_valve") + check(profile != null,"The explicit Steam Frame interaction profile must exist") + if profile == null: return + var paths: Dictionary = {} + for binding in profile.get_bindings(): + var name: String = binding.get_action().resource_name + if not paths.has(name): paths[name] = [] + paths[name].append(binding.get_binding_path()) + var required := { + "tactical_button":"/user/hand/left/input/dpad_down/click", + "power_remove":"/user/hand/left/input/bumper/click", + "power_previous":"/user/hand/right/input/x/click", + "power_next":"/user/hand/right/input/y/click", + "page_left":"/user/hand/left/input/dpad_left/click", + "page_right":"/user/hand/left/input/dpad_right/click", + "wheel_button":"/user/hand/right/input/bumper/click", + } + var actions: Dictionary = {} + for action in map.get_action_set(0).get_actions(): actions[action.resource_name] = action + for name in required: + check(paths.get(name,[]) == [required[name]],"Frame %s must use the actual observed hardware button path" % name) + check(actions.has(name) and actions[name].action_type == OpenXRAction.OPENXR_ACTION_BOOL,"Frame %s must be a boolean action in the active action set" % name) + if not actions.has(name): continue + var hand: String = "/user/hand/right" if name in ["power_previous","power_next","wheel_button"] else "/user/hand/left" + check(hand in actions[name].toplevel_paths,"Frame %s must include its actual hardware hand" % name) + check(paths.get("ax_button",[]) == ["/user/hand/right/input/a/click"],"Frame Dpad Down must not alias A/X and trigger both tactical and pause") + check("/user/hand/left/input/view/click" in paths.get("menu_button",[]),"Left View must retain pause") + check("/user/hand/left/input/dpad_up/click" in paths.get("by_button",[]),"Dpad Up must retain shortcuts") + # The installed vendor Frame profile declares X/Y on the RIGHT controller. + # Reject accidental Touch-controller assumptions (left X/Y) in this profile. + for bound_paths in paths.values(): + check(not "/user/hand/left/input/x/click" in bound_paths and not "/user/hand/left/input/y/click" in bound_paths,"Frame X/Y must not be bound to nonexistent left-hand buttons") + + +func _verify_native_fonts() -> void: + var assets = load("res://scripts/ftl_ui_assets.gd") + for role in ["body","header","bold"]: + var native: Font = assets.font(role) + check(native is FontFile and native != ThemeDB.fallback_font,"The %s UI font must load the actual owner-local FTL atlas without editor import" % role) + if native is FontFile and native != ThemeDB.fallback_font: + check(native.has_char(65) and native.has_char(233),"Native UI font must retain Western glyphs and accented crew names") + check(native.fixed_size > 0 and native.get_string_size("FTL",HORIZONTAL_ALIGNMENT_LEFT,-1,32).x > 0,"Native bitmap font must expose its original metrics and scalable advances") + check(assets.font(role) == native,"Native UI font instances must be cached rather than decoded on every label") + check(assets.load_font("res://local_game_data/fonts/missing.font") == null,"Missing local fonts must fall back without attempting system installation") + # Compare the runtime A glyph against the original SIL byte metrics/atlas. + var original := FileAccess.get_file_as_bytes(assets.data_path().path_join("fonts/JustinFont10.font")) + var body: FontFile = assets.font("body") + var info: int = assets._be32(original,8) + var height := int(original[5]) + for i in range(assets._be16(original,12)): + var offset: int = info + i * 16 + if assets._be32(original,offset) != 65: continue + var expected_advance: float = original[offset + 8] + assets._signed(assets._be16(original,offset + 12),16) / 256.0 + assets._signed(assets._be16(original,offset + 14),16) / 256.0 + check(is_equal_approx(body.get_glyph_advance(0,height,65).x,expected_advance),"Native font advances must preserve original fractional pre/post bearings") + check(body.get_glyph_offset(0,Vector2i(height,0),65).y + 2 == -assets._signed(original[offset + 10],8),"Native font baseline must retain original glyph ascent after transparent atlas padding") + var tex: int = assets._be32(original,16) + var pixel_offset: int = assets._be32(original,tex + 16) + var width: int = assets._be16(original,tex + 8) + var atlas := body.get_texture_image(0,Vector2i(height,0),0) + var original_x: int = assets._be16(original,offset + 4) + var original_y: int = assets._be16(original,offset + 6) + var uv: Rect2 = body.get_glyph_uv_rect(0,Vector2i(height,0),65) + var gx := int(uv.position.x) + 2 + var gy := int(uv.position.y) + 2 + for y in range(original[offset + 9]): + for x in range(original[offset + 8]): + check(roundi(atlas.get_pixel(gx + x,gy + y).a * 255) == original[tex + pixel_offset + (original_y + y) * width + original_x + x],"Native font atlas pixels must match the owned source, not a replacement alphabet") + break + var malformed := FileAccess.open("res://local_game_data/fonts/test-invalid.font",FileAccess.WRITE) + malformed.store_buffer(original.slice(0,24)) + malformed.close() + check(assets.load_font("res://local_game_data/fonts/test-invalid.font") == null,"Truncated native atlas/index data must fail cleanly") + DirAccess.remove_absolute(ProjectSettings.globalize_path("res://local_game_data/fonts/test-invalid.font")) + + +func _verify_world_font_render() -> void: + var assets = load("res://scripts/ftl_ui_assets.gd") + var preview := SubViewport.new() + preview.size = Vector2i(640,240) + preview.transparent_bg = true + preview.own_world_3d = true + preview.render_target_update_mode = SubViewport.UPDATE_ONCE + root.add_child(preview) + var camera := Camera3D.new() + camera.position.z = 2 + camera.projection = Camera3D.PROJECTION_ORTHOGONAL + camera.size = 2.4 + camera.current = true + preview.add_child(camera) + var labels: Array[Label3D] = [] + for i in range(2): + var label := Label3D.new() + label.font = assets.font("header" if i == 0 else "body") + label.text = "FTL HULL" if i == 0 else "Elisabeth / SHIELDS" + label.font_size = 48 + label.pixel_size = .012 + label.outline_size = 0 + label.texture_filter = BaseMaterial3D.TEXTURE_FILTER_NEAREST + label.position.y = .25 if i == 0 else -.25 + preview.add_child(label) + labels.append(label) + await rendered() + var image := preview.get_texture().get_image() + image.save_png("res://local_game_data/controller-world-native-fonts.png") + for half in range(2): + var opaque := 0 + for y in range(half * 120,(half + 1) * 120): + for x in range(640): + if image.get_pixel(x,y).a > .5: opaque += 1 + check(opaque > 50,"Native %s FontFile must render visible Label3D glyphs with zero outline" % ("header" if half == 0 else "body")) + # A raw tight atlas rendered broad letters while dropping every 2px glyph + # I/i/l in Label3D. Isolate those letters so the broad glyphs cannot mask it. + labels[0].visible = false + labels[1].text = "Iil" + preview.render_target_update_mode = SubViewport.UPDATE_ONCE + await rendered() + image = preview.get_texture().get_image() + image.save_png("res://local_game_data/controller-world-narrow-glyphs.png") + var narrow_pixels := 0 + for y in range(240): + for x in range(640): + if image.get_pixel(x,y).a > .5: narrow_pixels += 1 + check(narrow_pixels > 50,"Original narrow I/i/l glyphs must remain visible in nearest-filtered Label3D via padded UV quads") + preview.queue_free() diff --git a/tools/test_environment.gd b/tools/test_environment.gd new file mode 100644 index 0000000..8ac7d8c --- /dev/null +++ b/tools/test_environment.gd @@ -0,0 +1,52 @@ +extends SceneTree + +var failures := 0 + + +func _initialize() -> void: + call_deferred("_run") + + +func _check(condition: bool, message: String) -> void: + if not condition: + failures += 1 + push_error(message) + + +func _run() -> void: + var space: Node3D = load("res://scripts/space_environment.gd").new() + root.add_child(space) + space.build() + space.set_process(false) + _check(space.star_positions.size() == 1696, "Two star shells must cover the sky") + for axis in [Vector3.RIGHT, Vector3.LEFT, Vector3.UP, Vector3.DOWN, Vector3.FORWARD, Vector3.BACK]: + var count := 0 + for point in space.star_positions: + if Vector3(point).normalized().dot(axis) > 0.8: + count += 1 + _check(count > 100, "Every direction must contain stars, including overhead and behind") + var meshes := 0 + for child in space.get_children(): + if child is MultiMeshInstance3D: + meshes += 1 + _check(meshes == 3, "Starfield must batch its geometry into three draw calls") + for kind in ["asteroid", "sun", "storm", "nebula", "pulsar", "clear"]: + space.apply_state({"hazard": kind, "paused": false}) + _check(space.hazard == kind, "Environment must follow the native hazard") + _check(space.hazard_root.get_child_count() < 40, "Hazard geometry must stay bounded") + if kind != "clear": + _check(space.hazard_root.get_child_count() > 0, "Each active hazard must have a 3D presentation") + else: + _check(space.hazard_root.get_child_count() == 0, "Old hazard geometry must be removed at a clear beacon") + space.apply_state({"hazard": "asteroid", "paused": true}) + var rock: Node3D = space.rocks[0]["node"] + var location := rock.position + space._process(0.5) + _check(rock.position == location, "Pause must freeze nearby asteroid motion") + space.apply_state({"hazard": "asteroid", "paused": false}) + space._process(0.5) + _check(rock.position != location, "Unpaused asteroid field must have parallax motion") + print("ENVIRONMENT_TESTS ", "PASS" if failures == 0 else "FAIL: %d" % failures) + space.queue_free() + await process_frame + quit(0 if failures == 0 else 1) diff --git a/tools/test_environment.gd.uid b/tools/test_environment.gd.uid new file mode 100644 index 0000000..655e70e --- /dev/null +++ b/tools/test_environment.gd.uid @@ -0,0 +1 @@ +uid://b2jy3xjno2on2 diff --git a/tools/test_extract_ftl.py b/tools/test_extract_ftl.py new file mode 100644 index 0000000..04b8b5d --- /dev/null +++ b/tools/test_extract_ftl.py @@ -0,0 +1,80 @@ +import struct +import tempfile +import unittest +from pathlib import Path + +from extract_ftl import Pkg, build_ship, extract_fonts, parse_layout, system_rooms + + +class ExtractTests(unittest.TestCase): + def test_owned_font_bootstrap_copies_original_bytes_only(self): + original = b"FONT\x01" + bytes(19) + + class FakePkg: + entries = {"fonts/JustinFont10.font": original} + + def read(self, name): + return self.entries[name] + + with tempfile.TemporaryDirectory() as folder: + target = Path(folder) + self.assertEqual(extract_fonts(FakePkg(), target), ["fonts/JustinFont10.font"]) + self.assertEqual((target / "fonts/JustinFont10.font").read_bytes(), original) + self.assertEqual(list(target.rglob("*.ttf")), []) + FakePkg.entries["fonts/JustinFont10.font"] = b"bad font" + with self.assertRaises(ValueError): + extract_fonts(FakePkg(), target) + + def test_layout_room_and_door(self): + layout = parse_layout(b"X_OFFSET\n0\nROOM\n7\n3\n4\n2\n1\nDOOR\n4\n4\n7\n8\n1\n") + self.assertEqual(layout["rooms"], [{"id": 7, "x": 3, "y": 4, "w": 2, "h": 1}]) + self.assertEqual(layout["doors"][0]["b"], 8) + + def test_pkg_bounds_and_read(self): + name = b"data/test.txt\x00" + payload = b"FTL test" + offset = 16 + 20 + len(name) + header = b"PKG\n" + struct.pack(">HHII", 16, 20, 1, len(name)) + record = struct.pack(">I", 0) + b"\x00\x00\x00\x00" + struct.pack(">III", offset, len(payload), len(payload)) + with tempfile.TemporaryDirectory() as temp: + path = Path(temp) / "ftl.dat" + path.write_bytes(header + record + name + payload) + self.assertEqual(Pkg(path).read("data/test.txt"), payload) + + def test_system_room_mapping_only_includes_installed_systems(self): + xml = b'' + name, systems = system_rooms(xml, "kestral") + self.assertEqual(name, "PLAYER_SHIP_HARD") + self.assertEqual(systems, {"0": "pilot"}) + + def test_ship_texture_matches_native_dimensions_and_crop_offsets(self): + def png(width, height): + return b"\x89PNG\r\n\x1a\n" + struct.pack(">I", 13) + b"IHDR" + struct.pack(">II", width, height) + + class FakePkg: + entries = { + "data/test.txt": b"ROOM 0 0 0 2 2", + "data/test.xml": b'', + "data/blueprints.xml": b"", + "img/ships_noglow/test_base.png": png(60, 80), + "img/ships_glow/test_base.png": png(100, 120), + } + + def read(self, name): + return self.entries[name] + + with tempfile.TemporaryDirectory() as folder: + target = Path(folder) + result = build_ship(FakePkg(), "test", target) + self.assertEqual(result["image_path"], "img/ships_glow/test_base.png") + result = build_ship(FakePkg(), "test", target, + native_image_rect={"x": 5, "y": -7, "w": 60, "h": 80}) + self.assertEqual(result["image_path"], "img/ships_noglow/test_base.png") + import json + saved = json.loads((target / "test.json").read_text(encoding="utf-8")) + self.assertEqual(saved["image_rect"], {"x": 5, "y": -7, "w": 60, "h": 80}) + self.assertEqual(saved["texture_size"], [60, 80]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/test_frame_transport.gd b/tools/test_frame_transport.gd new file mode 100644 index 0000000..8ef9018 --- /dev/null +++ b/tools/test_frame_transport.gd @@ -0,0 +1,91 @@ +extends SceneTree + +var failures := 0 +var transport_path := "res://local_game_data/transport-test/screen_frame.rgba" + + +func check(value: bool, message: String) -> void: + if not value: + failures += 1 + push_error(message) + + +func _initialize() -> void: + call_deferred("run") + + +func write_frame(image: Image, sequence: int) -> void: + var header := PackedByteArray() + header.resize(36) + for i in range(4): header[i] = "FVR1".to_ascii_buffer()[i] + header.encode_u32(4, 1) + header.encode_u32(8, image.get_width()) + header.encode_u32(12, image.get_height()) + header.encode_u32(16, 4) + header.encode_u64(20, sequence) + header.encode_double(28, 123.5) + var file := FileAccess.open(transport_path, FileAccess.WRITE) + file.store_buffer(header) + file.store_buffer(image.get_data()) + file.close() + + +func run() -> void: + var surface: Node3D = load("res://scripts/hud_surface.gd").new() + surface.frame_file = "screen_frame.png" + root.add_child(surface) + DirAccess.make_dir_recursive_absolute(ProjectSettings.globalize_path("res://local_game_data/transport-test")) + surface.canvas.base_path = ProjectSettings.globalize_path("res://local_game_data/transport-test") + surface.set_process(false) + var image := Image.create(960, 540, false, Image.FORMAT_RGBA8) + image.fill(Color(0.1, 0.8, 0.2, 1)) + image.set_pixel(536, 281, Color(0.9, 0.1, 0.2, 0)) + write_frame(image, 1) + surface.canvas.poll_original_frame() + check(surface.canvas.frame_image.get_size() == Vector2i(960, 540), "Live raw frames must retain their reduced transport dimensions") + check(surface.canvas.frame_image.get_pixel(0, 0).g > 0.7, "Raw frame orientation must be top-down RGBA") + var texture: Texture2D = surface.canvas.original_frame + var count: int = surface.canvas.received_frames + surface.canvas.poll_original_frame() + check(surface.canvas.received_frames == count, "An unchanged raw sequence must skip texture upload") + var broken := FileAccess.open(transport_path, FileAccess.WRITE) + broken.store_buffer("FVR1".to_ascii_buffer()) + broken.close() + surface.canvas.poll_original_frame() + check(surface.canvas.received_frames == count and surface.canvas.original_frame == texture, "Truncated raw headers must retain the last complete frame") + write_frame(image, 1) + broken = FileAccess.open(transport_path, FileAccess.READ_WRITE) + broken.seek(8) + broken.store_32(5000) + broken.close() + surface.canvas.poll_original_frame() + check(surface.canvas.received_frames == count, "Malformed raw dimensions must never allocate or upload a texture") + check(surface.canvas.native_alpha_at(Vector2i(715, 375)) < 0.1, "Native alpha picking must sample reduced image coordinates") + image.set_pixel(536, 281, Color(0.9, 0.1, 0.2, 1)) + write_frame(image, 2) + surface.canvas.poll_original_frame() + check(surface.canvas.original_frame == texture, "Reduced raw frames must reuse their GPU texture") + surface.set_crop(Rect2(335, 90, 760, 570), Vector2(1.02, 0.765)) + var center: Variant = surface.ray_pixel(Vector3(0, 0, 1), Vector3.FORWARD) + check(center == Vector2i(715, 375), "Floating map centers must round to native pixels rather than truncate") + check(surface.canvas.native_used_rect() == Rect2i(0, 0, 1280, 720), "Panel bounds must be expressed in native pixels") + check(surface.viewport.size == Vector2i(960, 540), "Tactical viewport must match its reduced transport") + for i in range(4): await process_frame + if RenderingServer.get_current_rendering_method() != "gl_compatibility" and not DisplayServer.get_name() == "headless": + await RenderingServer.frame_post_draw + var rendered: Image = surface.viewport.get_texture().get_image() + check(rendered.get_pixel(0, 0).g > 0.7, "Reduced native texture crop must render through the logical canvas") + var full := Image.create(1280, 720, false, Image.FORMAT_RGBA8) + full.fill(Color(0.2, 0.4, 0.9, 1)) + write_frame(full, 3) + surface.canvas.poll_original_frame() + check(surface.viewport.size == Vector2i(1280, 720), "Full-resolution jump-map frames must restore the native viewport resolution") + check(surface.viewport.size_2d_override == Vector2i(1280, 720) and surface.viewport.size_2d_override_stretch, "Resolution changes must preserve the logical native canvas") + check(surface.ray_pixel(Vector3(0, 0, 1), Vector3.FORWARD) == Vector2i(715, 375), "Native map picking must remain identical after resolution changes") + write_frame(image, 4) + surface.canvas.poll_original_frame() + check(surface.viewport.size == Vector2i(960, 540), "Returning to Tactical must reduce the viewport again") + DirAccess.remove_absolute(ProjectSettings.globalize_path(transport_path)) + DirAccess.remove_absolute(ProjectSettings.globalize_path("res://local_game_data/transport-test")) + print("FRAME_TRANSPORT_TESTS ", "PASS" if failures == 0 else "FAIL %d" % failures) + quit(0 if failures == 0 else 1) diff --git a/tools/test_input.gd b/tools/test_input.gd new file mode 100644 index 0000000..3816b2d --- /dev/null +++ b/tools/test_input.gd @@ -0,0 +1,390 @@ +extends SceneTree +var failures := 0 +func check(condition: bool, message: String) -> void: + if not condition: + failures += 1 + push_error(message) +func _initialize() -> void: + call_deferred("run") +func run() -> void: + var scene := load("res://tools/input_harness.gd").new() as Node3D + root.add_child(scene) + await process_frame + scene.set_process(false) + var tracker := XRControllerTracker.new() + tracker.type = XRServer.TRACKER_CONTROLLER + tracker.name = "right_hand" + tracker.set_pose("aim", Transform3D(Basis.IDENTITY, Vector3(0.1,1.2,1)), Vector3.ZERO, Vector3.ZERO, XRPose.XR_TRACKING_CONFIDENCE_HIGH) + XRServer.add_tracker(tracker) + scene._resolve_controller_pose(scene.right_hand) + check(scene.right_hand.pose == "aim" and scene.right_hand.get_is_active(), "Right hand must use the active aim tracker pose") + scene._update_pointer() + check(scene.right_laser.visible and scene.right_laser.mesh is CylinderMesh, "Tracked hand must draw a stereo-visible ray") + check(scene.right_laser.material_override.no_depth_test, "Ray must remain visible over the native menu") + check(scene.right_laser.material_override.transparency == BaseMaterial3D.TRANSPARENCY_ALPHA and scene.right_laser.material_override.render_priority == 127,"Pointer must render after transparent HUD surfaces") + check(is_equal_approx(scene.right_laser.mesh.height,1.0),"Pointer length must use node scaling, without regenerating its mesh every frame") + tracker.set_input("trigger",0.8) + check(scene._pressed(scene.right_hand,"trigger"), "Analog trigger must work without trigger_click") + tracker.invalidate_pose("aim") + tracker.set_pose("default",Transform3D.IDENTITY,Vector3.ZERO,Vector3.ZERO,XRPose.XR_TRACKING_CONFIDENCE_HIGH) + scene._resolve_controller_pose(scene.right_hand) + check(scene.right_hand.pose == "default", "Missing aim tracking must fall back to default") + scene._update_input_diagnostics(1.1) + scene.hud_state = {"ready":true,"ui_mode":"game","navigation":{"jump":true,"ship":true,"store":false}} + scene.xr_active = true + scene._refresh_navigation() + var names := [] + for button in scene.nav_buttons: + if button.visible: names.append(button.get_meta("action")) + else: check(button.collision_layer == 0,"Hidden navigation buttons must not intercept rays") + check("jump" in names and "store" not in names,"Navigation must reflect live availability") + scene.hud_state.map_open = true + scene._refresh_navigation() + check(scene.map_surface.visible and scene.map_surface.get_parent() == scene,"Actual jump map must float in the world above the ship") + check(scene.map_surface.global_position.y >= scene.player_ship.global_position.y + 0.65,"Jump map must have comfortable clearance above the ship") + var pilot_id := -1 + for id in scene.player_ship.room_nodes: + if scene.player_ship.room_nodes[id].get_meta("system_role", "") == "pilot": pilot_id = int(id) + check(pilot_id >= 0,"Owned Kestrel layout must provide its actual piloting room") + if pilot_id >= 0: + var pilot_point: Vector3 = scene.player_ship.to_global(scene.player_ship.room_points[pilot_id]) + var bow: Vector3 = scene.player_ship.global_basis.x.normalized() + var projected: Vector3 = scene.map_surface.global_position - pilot_point + check(projected.dot(bow) > 0.20 and projected.y > 0.60,"Jump map must project above and ahead of the pilot instead of the ship center") + var original_table: Transform3D = scene.tabletop_root.transform + scene.tabletop_root.rotation.y = PI * 0.5 + scene.tabletop_root.scale *= 1.8 + scene._position_world_panels() + pilot_point = scene.player_ship.to_global(scene.player_ship.room_points[pilot_id]) + bow = scene.player_ship.global_basis.x.normalized() + projected = scene.map_surface.global_position - pilot_point + check(projected.dot(bow) > 0.20 and projected.y > 0.60 and projected.length() < 0.70,"Pilot projection must follow ship rotation while keeping world-metre clearance at different table scales") + var saved_vertical: int = int(scene.player_ship.layout_data.get("vertical",0)) + scene.player_ship.layout_data.vertical = 1 + scene._update_ship_positions() + scene._position_world_panels() + pilot_point = scene.player_ship.to_global(scene.player_ship.room_points[pilot_id]) + projected = scene.map_surface.global_position - pilot_point + check(projected.dot(-scene.player_ship.global_basis.z.normalized()) > 0.20,"Vertical ship layouts must project ahead of their actual bow") + scene.player_ship.layout_data.vertical = saved_vertical + scene._update_ship_positions() + scene.tabletop_root.transform = original_table + # Read the actual room roles, not the extracted blueprint's first pilot. + var alternate: int = int(scene.player_ship.room_nodes.keys().back()) + if alternate != pilot_id: + scene.player_ship.room_nodes[pilot_id].set_meta("system_role", "") + var saved_role: String = scene.player_ship.room_nodes[alternate].get_meta("system_role", "") + scene.player_ship.room_nodes[alternate].set_meta("system_role", "pilot") + scene._position_world_panels() + pilot_point = scene.player_ship.to_global(scene.player_ship.room_points[alternate]) + check(Vector2(scene.map_surface.global_position.x-pilot_point.x,scene.map_surface.global_position.z-pilot_point.z).length() < 0.25,"A different piloting room must move the projected map anchor with it") + scene.player_ship.room_nodes[alternate].set_meta("system_role", saved_role) + scene.player_ship.room_nodes[pilot_id].set_meta("system_role", "pilot") + scene._position_world_panels() + check(is_equal_approx(scene.enemy_hull_bar.global_position.y-scene.enemy_ship.global_position.y,0.52),"Enemy hull bar must sit slightly lower while retaining clearance over the ship") + scene.hud_state.map_open = false + scene.hud_state.tactical = true + scene._refresh_navigation() + check(scene.nav_buttons[0].get_meta("action") == "tactical","Tactical view must close with its own toggle, not Escape") + scene.hud_state.map_open = true + scene.hud_state.tactical = false + scene._refresh_navigation() + var image := Image.create(1280,720,false,Image.FORMAT_RGBA8) + image.fill(Color.WHITE) + scene.map_surface.canvas.frame_image = image + scene.nav_panel.visible = true + var pixel: Variant = scene.map_surface.ray_pixel(scene.map_surface.to_global(Vector3(0,0,1)), -scene.map_surface.global_basis.z) + check(pixel == Vector2i(715,375),"Cropped map pointer must map to the native game's original coordinates") + scene._select_from_ray(scene.map_surface.to_global(Vector3(0,0,1)), -scene.map_surface.global_basis.z) + scene._release_pointer(scene.map_surface.to_global(Vector3(0,0,1)), -scene.map_surface.global_basis.z) + check(scene.commands.size() == 2 and scene.commands[0].data.phase == "down" and scene.commands[1].data.phase == "up","Map selection must dispatch a full native mouse click") + scene.nav_panel.visible = false + check(scene.map_surface.ray_pixel(scene.map_surface.to_global(Vector3(0,0,1)), -scene.map_surface.global_basis.z) != null,"World jump map must stay usable when hand tracking is hidden") + var map: OpenXRActionMap = load("res://openxr_action_map.tres") + check(map.find_interaction_profile("/interaction_profiles/valve/frame_controller_valve") != null,"Explicit Frame profile must ship with the client") + check(is_equal_approx(scene.nav_panel.scale.x, 0.6) and is_equal_approx(scene.hud_surface.scale.x, 0.64) and scene.hud_surface.position.y < -0.1,"HUD must be lower and narrower while retaining the hand panel size") + check(scene.hud_surface.render_order == 100 and scene.panel_theme.get_child(1).material_override.render_priority > scene.pause_label.render_priority and scene.tracking_label.render_priority < 120,"Hand screen must render over all HUD layers, including pause and modifier labels") + check(scene.help_label.get_script().resource_path.ends_with("controller_help.gd"),"Help must use a controller infographic instead of plain text") + check(scene.hover_marker.mesh.radius <= 0.003 and scene.right_laser.mesh.top_radius <= 0.001,"Pointer endpoint must support precise native UI selection") + scene.wheel.global_position = scene.camera.get_camera_transform().origin + Vector3(0.3,-0.5,-0.4) + scene._face_front(scene.wheel) + var to_head: Vector3 = (scene.camera.get_camera_transform().origin-scene.wheel.global_position).normalized() + check(scene.wheel.global_basis.z.dot(to_head) > 0.999 and scene.wheel.global_basis.z.y > 0,"Wheel face must tilt up toward the real viewer rather than the ground") + scene.hud_state = {"ready":true,"ui_mode":"game","navigation":{"jump":true},"player":{"weapons":[{}],"systems":{"shields":true}},"drone_slots":1} + scene.nav_page = "navigation" + scene._cycle_hand_page(1) + check(scene.nav_page == "tactical" and scene.commands.back().data.screen == "tactical","Dpad right must cycle into tactical") + scene.hud_state.tactical = true + scene._cycle_hand_page(1) + check(scene.pending_page == "shortcuts" and scene.commands.back().data.screen == "tactical","Changing screen must first close the native tactical view") + scene.hud_state.tactical = false + scene._refresh_wheel() + check(scene.wheel.entries[0].enabled and not scene.wheel.entries[1].enabled and scene.wheel.entries[4].enabled,"Wheel must show only equipped weapon/drone slots as available") + scene.wheel.choose(Vector2(0,1)) + check(scene.wheel.selected == 0,"Joystick up must select slot 1") + scene.wheel.choose(Vector2.ZERO) + check(scene.wheel.selected == -1,"Wheel center must cancel") + scene.hud_state.event_open = true + scene.hud_state.dialog = {"id":"test","choices":[{"index":0,"enabled":true}]} + scene.wheel_dialog_id = "test" + scene._refresh_wheel() + scene.wheel.choose(Vector2(0,1)) + scene._commit_wheel() + var command_count: int = scene.commands.size() + scene._commit_wheel() + check(scene.commands.back().action == "event_choice" and scene.commands.size() == command_count,"Event wheel must commit once") + scene.event_surface.visible = true + scene.event_surface.canvas.frame_image = image + scene.event_surface.source_rect = Rect2(313,138,650,390) + var event_pixel: Variant = scene.event_surface.ray_pixel(scene.event_surface.to_global(Vector3(0,0,1)), -scene.event_surface.global_basis.z) + check(event_pixel == Vector2i(638,333),"Cropped event ray must retain the original native coordinates") + # A visible HUD pixel takes precedence over a physically nearer world panel. + scene.hud_surface.canvas.frame_image = image + scene.hud_surface.global_transform = Transform3D.IDENTITY + scene.world_surface.global_transform = Transform3D(Basis.IDENTITY,Vector3(0,0,0.4)) + scene.world_surface.visible = true + scene.world_surface.canvas.frame_image = image + var top_hit: Dictionary = scene._surface_hit(Vector3(0,0,1),Vector3.FORWARD) + check(top_hit.surface == scene.hud_surface,"Rendered HUD priority must match click priority") + # A hand map wins against an overlapping opaque HUD pixel, regardless of distance. + scene.nav_panel.global_transform = Transform3D(Basis.IDENTITY,Vector3(0,0,0.4)) + scene.nav_panel.visible = true + scene.hud_state.map_open = false + scene.hud_state.tactical = true + scene._refresh_navigation() + scene.map_surface.visible = true + scene.map_surface.canvas.frame_image = image + var hand_top: Dictionary = scene._surface_hit(Vector3(0,0,1),Vector3.FORWARD) + check(hand_top.get("surface") == scene.map_surface,"Controller screen click priority must match its foreground draw priority") + scene.map_surface.visible = false + scene._select_from_ray(Vector3(0,0,1),Vector3.FORWARD) + check(scene.held_surface == null,"Blank hand panel pixels must block clicks into the HUD behind them") + scene.nav_panel.visible = false + var window_image := Image.create(1280,720,false,Image.FORMAT_RGBA8) + var body_image := Image.create(650,520,false,Image.FORMAT_RGBA8) + body_image.fill(Color.WHITE) + window_image.blit_rect(body_image,Rect2i(0,0,650,520),Vector2i(300,90)) + scene.world_surface.canvas.frame_image = window_image + scene.world_panel_crop_pending = true + scene.world_panel_open_age = 0.3 + scene.world_panel_opened_at = 100.0 + scene.world_surface.canvas.capture_time_unix = 99.0 + var old_crop: Rect2 = scene.world_surface.source_rect + scene._position_world_panels() + check(scene.world_panel_crop_pending and scene.world_surface.source_rect == old_crop,"A previous panel's cached frame must not lock a new window crop") + scene.world_surface.canvas.capture_time_unix = 100.1 + scene._position_world_panels() + var fixed_crop: Rect2 = scene.world_surface.source_rect + check(fixed_crop == Rect2(300,90,650,520),"Native world window crop must preserve its original pixel coordinates") + check(scene.world_surface.global_position.y > scene.player_ship.global_position.y,"Ship and store windows must hang above the VR player ship") + scene.world_surface.canvas.frame_image = image + scene._position_world_panels() + check(scene.world_surface.source_rect == fixed_crop,"Hover image changes must not resize a floating window or change pointer mapping") + scene.world_surface.canvas.capture_time_unix = 0.0 + scene.world_panel_open_frame_count = scene.world_surface.canvas.received_frames + check(not scene._world_panel_has_fresh_frame(),"Legacy PNG transport must wait for a new image on panel opening") + scene.world_surface.canvas.received_frames += 1 + check(scene._world_panel_has_fresh_frame(),"A newly decoded legacy PNG must remain usable without a raw timestamp") + scene.world_panel_kind = "sell" + scene.world_panel_crop_pending = true + scene._position_world_panels() + check(scene.world_surface.source_rect == Rect2(0,0,1280,720),"Shop crop must retain detached sell drop boxes and future hover panels") + check(scene.world_surface.surface_size.x <= 1.03 and scene.event_surface.surface_size.x < 1.0,"Floating ship/store and event panels must be smaller") + # The authoritative pause cue does not inherit a cached native image or forced menu freeze. + scene.hud_state = {"ready":true,"paused":true} + scene.paused = true + scene._update_hud() + scene._refresh_navigation() + check(scene.pause_label.visible and "PAUSED" in scene.nav_title.text,"Paused status must be present on HUD and hand screen from the same state") + scene.hud_state = {"ready":true,"paused":false,"frozen":true} + scene._update_hud() + scene._refresh_navigation() + check(not scene.pause_label.visible and "PAUSED" not in scene.nav_title.text,"A menu freeze must not leave a stale player-pause cue") + # Text buttons send native text operations, with no leaked UI mouse-up/hotkeys. + scene.wheel.visible = true + scene.wheel_committed = false + scene._apply_text_entry({"active":true,"id":"rename-1","kind":"crew","text":"Test","cursor":4}) + check(not scene.wheel.visible and scene.wheel_committed,"Native renaming must cancel an already-open action wheel") + var count_before_modal_wheel: int = scene.commands.size() + scene.wheel_committed = false + scene._commit_wheel() + check(scene.commands.size() == count_before_modal_wheel,"Wheel release must not send a gameplay key into native text entry") + scene.rename_keyboard.global_transform = Transform3D.IDENTITY + var key: Dictionary = scene.rename_keyboard.keys[10] + var key_pixel: Vector2 = key.rect.get_center() + var key_world: Vector3 = scene.rename_keyboard.to_global(Vector3((key_pixel.x/1000.0-0.5)*0.86,(0.5-key_pixel.y/454.0)*0.39,0)) + scene._select_from_ray(key_world+Vector3(0,0,1),Vector3.FORWARD) + check(scene.commands.back().action == "text_input" and scene.commands.back().data.op == "insert" and scene.commands.back().data.text == "Q" and scene.commands.back().data.entry_id == "rename-1","VR keyboard must insert into the exact active native name field") + var count_before_release: int = scene.commands.size() + scene._release_pointer(key_world+Vector3(0,0,1),Vector3.FORWARD) + check(scene.commands.size() == count_before_release,"Keyboard release must not leak a native UI click") + scene._select_from_ray(Vector3(0,20,0),Vector3.DOWN) + check(scene.commands.size() == count_before_release,"Active renaming must block background HUD, menu and scene clicks") + scene._secondary_click(Vector3.ZERO,Vector3.DOWN) + check(scene.commands.back().action == "text_input" and scene.commands.back().data.op == "cancel","B must cancel the native rename field") + scene.rename_keyboard.set_entry({"active":false}) + for surface in [scene.hud_surface,scene.world_surface,scene.event_surface,scene.map_surface]: surface.visible = false + scene.hud_state = {"ready":true,"ui_mode":"screen","tactical":true} + scene.demo_mode = false + scene.bridge_connected = true + # Weapon targets and beam endpoints ignore miniatures and door colliders. + scene.weapon_selected = true + scene.battle = true + var enemy_room: int = scene.enemy_ship.room_points.keys()[0] + var enemy_point: Vector3 = scene.enemy_ship.room_target(enemy_room) + var obstruction := Area3D.new() + obstruction.set_meta("kind","door") + obstruction.set_meta("ship","enemy") + var obstruction_shape := CollisionShape3D.new() + var obstruction_box := BoxShape3D.new() + obstruction_box.size = Vector3(0.1,0.1,0.1) + obstruction_shape.shape = obstruction_box + obstruction.add_child(obstruction_shape) + scene.add_child(obstruction) + obstruction.global_position = enemy_point + Vector3(0,0.2,0) + await physics_frame + scene._select_from_ray(enemy_point+Vector3(0,1,0),Vector3.DOWN) + check(scene.commands.back().action == "target_room" and scene.commands.back().data.room_id == enemy_room,"Weapon room selection must bypass enemy crew and door colliders") + scene.beam_drag = true + scene._release_pointer(enemy_point+Vector3(0,1,0),Vector3.DOWN) + check(scene.commands.back().action == "target_room" and scene.commands.back().data.phase == "up","Beam release must use the same room-floor picking") + for kind in ["mind", "hacking", "teleporter"]: + scene.hud_state.targeting = {"active":true,"kind":kind,"ships":["enemy"]} + scene._select_from_ray(enemy_point+Vector3(0,1,0),Vector3.DOWN) + check(scene.commands.back().action == "target_room" and scene.commands.back().data.ship == "enemy" and not scene.beam_drag,"Native %s targeting must work on 3D enemy room floors" % kind) + scene.hud_state.targeting = {"active":false} + obstruction.queue_free() + var room_id: int = scene.player_ship.room_points.keys()[0] + var room_point: Vector3 = scene.player_ship.room_target(room_id) + var crew := Area3D.new() + crew.set_meta("kind","crew") + crew.set_meta("crew_id","grab-test") + crew.set_meta("ship","player") + var shape := CollisionShape3D.new() + var sphere := SphereShape3D.new() + sphere.radius = 0.04 + shape.shape = sphere + crew.add_child(shape) + scene.add_child(crew) + crew.global_position = room_point + Vector3(0,0.2,0) + await physics_frame + scene.hud_state.blocking_ui = true + var blocked_count: int = scene.commands.size() + scene._select_from_ray(room_point+Vector3(0,1,0),Vector3.DOWN) + check(scene.commands.size() == blocked_count,"Real native dialogs must block background crew commands even in Tactical") + scene.hud_state.blocking_ui = false + scene.hud_state.targeting = {"active":true,"kind":"mind","ships":["player","enemy"]} + scene._select_from_ray(room_point+Vector3(0,1,0),Vector3.DOWN) + check(scene.commands.back().action == "target_room" and scene.commands.back().data.ship == "player" and scene.grabbed_crew == null,"Friendly mind control must target the room instead of grabbing its crew") + scene.hud_state.targeting = {"active":false} + scene.shift_down = true + scene._select_from_ray(room_point+Vector3(0,1,0),Vector3.DOWN) + check(scene.grabbed_crew == crew and scene.commands.back().action == "select_crew","Holding trigger on crew must begin a native selection and visual grab") + scene.crew_id_selected = "older-native-selected-crew" + scene.shift_down = false + scene._release_pointer(room_point+Vector3(0,1,0),Vector3.DOWN) + check(scene.grabbed_crew == null and scene.commands.back().action == "move_crew" and scene.commands.back().data.room_id == room_id and scene.commands.back().data.crew_id == "grab-test","Dropping crew must retain the grabbed id despite asynchronous native selection updates") + check(scene.commands.back().data.modifiers.shift,"Quick Shift-grabs must retain group selection even when grip is released before native selection acknowledgement") + scene._begin_crew_grab(crew,room_point+Vector3(0,1,0)) + scene._secondary_click(Vector3(0,20,0),Vector3.UP) + check(scene.grabbed_crew == null and scene.commands.back().action == "cancel","B must cancel the grab without issuing a move") + crew.queue_free() + scene.nav_page = "power" + scene.hud_state.tactical = false + scene.hud_state.ui_mode = "game" + scene.hud_state.player = {"system_status":[{"id":0,"key":"shields","powerable":true,"allocated":2,"max_power":4},{"id":1,"key":"engines","powerable":true,"allocated":1,"max_power":3}]} + scene._refresh_navigation() + scene.nav_panel.visible = true + scene.nav_panel.global_transform = Transform3D.IDENTITY + var card: Rect2 = scene.panel_theme.power_card_rect(0) + var card_pixel: Vector2 = card.position + Vector2(3,card.size.y*0.5) + var card_point := Vector3((card_pixel.x/1150.0-0.5)*0.92,(0.5-card_pixel.y/900.0)*0.72,0.012) + var oblique_from := card_point + Vector3(-0.65,0,0.3) + var oblique_hit: Dictionary = scene._hand_panel_hit(oblique_from,(card_point-oblique_from).normalized()) + check(oblique_hit.get("system_key", "") == "shields","Oblique power-page pointing must hit the visible card edge on the actual display plane") + card = scene.panel_theme.power_card_rect(1) + card_pixel = card.get_center() + card_point = Vector3((card_pixel.x/1150.0-0.5)*0.92,(0.5-card_pixel.y/900.0)*0.72,0.012) + scene.pointer_transform = Transform3D(Basis.IDENTITY,card_point+Vector3(0,0,1)) + scene.ray_filter.initialized = true + scene._update_pointer() + check(scene.panel_theme.selected_system().key == "engines","Power pointing must select the current card before a same-frame left trigger press") + scene.panel_theme.step_selection(-1) + scene._update_pointer() + scene._hover_ui(scene.pointer_transform.origin,Vector3.FORWARD) + check(scene.panel_theme.selected_system().key == "shields","A stationary power-page ray must not override X/Y system selection") + scene.ray_filter.reset() + scene.panel_theme.select_system("shields") + scene.nav_panel.visible = false + scene._change_system_power(1) + check(scene.commands.back().action == "system_power" and scene.commands.back().data.system_id == 0 and scene.commands.back().data.direction == 1 and not scene.commands.back().data.modifiers.shift,"Power trigger must use installed system identity and neutral modifiers") + scene._update_power_controls(true,false,true) + check(scene.commands.back().data.system_id == 1 and scene.commands.back().data.direction == -1,"X/Y selection and bumper removal must act on the newly selected system") + var held_count: int = scene.commands.size() + scene._update_power_controls(true,false,true) + check(scene.commands.size() == held_count,"Held bumper and X/Y must dispatch once per press") + scene._update_power_controls(false,false,false) + scene._update_power_controls(false,true,false) + check(scene.panel_theme.selected_system().key == "shields","Previous-system button must wrap back through installed systems") + scene.nav_page = "tactical" + scene.pending_page = "" + scene.hud_state.tactical = true + scene.hud_state.ui_mode = "screen" + scene._toggle_tactical() + check(scene.commands.back().action == "navigate" and scene.commands.back().data.screen == "tactical","Dpad Down must close Tactical without pausing") + scene.hud_state.tactical = false + scene.hud_state.ui_mode = "game" + scene.pending_page = "" + scene._toggle_tactical() + check(scene.commands.back().data.screen == "tactical","Dpad Down must open Tactical from gameplay") + check(scene.pause_label.position.y > 0.0,"Authoritative pause cue must remain above the HUD center, clear of Autofire") + var filter := preload("res://scripts/pose_filter.gd").new() + var steady := Transform3D(Basis.IDENTITY,Vector3(0.2,1.2,-0.3)) + filter.update(steady,1.0/90.0) + for i in range(90): + var jitter := steady + jitter.origin.x += 0.001 if i%2==0 else -0.001 + jitter.basis = Basis(Vector3.UP,deg_to_rad(0.1 if i%2==0 else -0.1)) + filter.update(jitter,1.0/90.0) + check(filter.value.is_equal_approx(steady),"Submillimetre hand/ray jitter must not move a stationary panel") + var moved := steady + moved.origin.x += 0.12 + for i in range(9): filter.update(moved,1.0/90.0) + check(filter.value.origin.distance_to(moved.origin)<0.012,"Deliberate hand movement must follow within a tenth of a second") + filter.reset() + check(filter.update(moved,1.0/90.0).is_equal_approx(moved),"Reacquired tracking must snap rather than retain the previous pose") + # Recenter is explicit; combat arrival must preserve the player's ship anchor. + scene.xr_active = true + scene._recenter() + var head_point: Vector3 = scene.camera.get_camera_transform().origin + var offset: Vector3 = scene.tabletop_root.global_position - head_point + var forward: Vector3 = -scene.camera.get_camera_transform().basis.z + forward.y = 0 + check(Vector2(offset.x,offset.z).length() > 0.8 and offset.dot(forward.normalized()) > 0.8 and is_equal_approx(offset.y,-0.95),"Recenter must place the table in front of the headset at its established height") + scene.battle = false + scene._update_ship_positions() + var player_position: Vector3 = scene.player_ship.global_position + scene.battle = true + scene._update_ship_positions() + check(scene.player_ship.global_position.is_equal_approx(player_position),"Another ship arriving must not shift the player's placed table") + var head_tracker := XRPositionalTracker.new() + head_tracker.name = "head" + head_tracker.type = XRServer.TRACKER_HEAD + head_tracker.set_pose("default",Transform3D(Basis.IDENTITY,Vector3(1.4,1.65,-0.9)),Vector3.ZERO,Vector3.ZERO,XRPose.XR_TRACKING_CONFIDENCE_HIGH) + XRServer.add_tracker(head_tracker) + scene.initial_table_placed = false + scene._place_initial_table() + check(scene.initial_table_placed,"First valid headset pose must place the tabletop exactly once") + var initial_position: Vector3 = scene.tabletop_root.global_position + head_tracker.set_pose("default",Transform3D(Basis.IDENTITY,Vector3(2.4,1.65,-0.9)),Vector3.ZERO,Vector3.ZERO,XRPose.XR_TRACKING_CONFIDENCE_HIGH) + scene._place_initial_table() + check(scene.tabletop_root.global_position.is_equal_approx(initial_position),"Walking after initial placement must not drag the ship with the headset") + head_tracker.set_pose("default",Transform3D.IDENTITY,Vector3.ZERO,Vector3.ZERO,XRPose.XR_TRACKING_CONFIDENCE_HIGH) + scene._recenter() + check(scene.player_ship.global_position.y < scene.camera.get_camera_transform().origin.y - 0.3,"Head-relative XR spaces with zero head height must still place the ship below the headset") + XRServer.remove_tracker(head_tracker) + XRServer.remove_tracker(tracker) + scene.queue_free() + await process_frame + print("INPUT_TESTS ", "PASS" if failures == 0 else "FAIL %d" % failures) + quit(0 if failures == 0 else 1) diff --git a/tools/test_input.gd.uid b/tools/test_input.gd.uid new file mode 100644 index 0000000..33d832e --- /dev/null +++ b/tools/test_input.gd.uid @@ -0,0 +1 @@ +uid://sr4hnfgun5is diff --git a/tools/test_models.gd b/tools/test_models.gd new file mode 100644 index 0000000..676c124 --- /dev/null +++ b/tools/test_models.gd @@ -0,0 +1,288 @@ +extends SceneTree + +const ShipModel = preload("res://scripts/ship_model.gd") +const CrewModel = preload("res://scripts/voxel_crew.gd") +const WeaponModel = preload("res://scripts/voxel_weapon.gd") +const DroneModel = preload("res://scripts/voxel_drone.gd") +const UiAssets = preload("res://scripts/ftl_ui_assets.gd") +const HullBar = preload("res://scripts/enemy_hull_bar.gd") +var failures := 0 + + +func _initialize() -> void: + call_deferred("_run") + + +func _check(ok: bool, message: String) -> void: + if not ok: + failures += 1 + push_error(message) + + +func _signature(node: Node) -> String: + var result := "" + if node is MeshInstance3D: + var arrays: Array = node.mesh.surface_get_arrays(0) + result += str(arrays[Mesh.ARRAY_VERTEX]) + for child in node.get_children(): + result += _signature(child) + return result + + +func _run() -> void: + var signatures: Dictionary = {} + for race in CrewModel.SPECIES: + var model := CrewModel.new() + root.add_child(model) + model.build(race) + var signature := _signature(model) + _check(not signatures.has(signature), "%s must have its own geometry, not a recolor" % race) + signatures[signature] = true + var poses: Dictionary = {} + for state in ["work", "repair", "run", "fight"]: + model.set_state({"working": state == "work", "repairing": state == "repair", "running": state == "run", "fighting": state == "fight"}) + model.animation_time = 0.31 + model.animate(0.11) + var pose := str(model.arms[0].rotation) + str(model.arms[1].rotation) + str(model.torso.position) + str(model.torso.rotation) + _check(model.animation_state == state, "%s must select native %s animation" % [race, state]) + _check(not poses.has(pose), "%s %s must have a distinct animated pose" % [race, state]) + poses[pose] = true + _check(model.tool.visible == (state == "repair"), "Repair tool must only appear during repairs") + model.queue_free() + var drone_signatures: Dictionary = {} + for family in ["combat", "beam", "defense", "hacking", "shield", "boarding", "anti_drone", "repair", "battle", "ion_boarder"]: + var drone := DroneModel.new() + root.add_child(drone) + var exterior: bool = family not in ["repair", "battle", "ion_boarder"] + var native := {"kind": family, "name": family.to_upper() + "_1", "species": family, "is_space": exterior, "powered": true, "deployed": true} + drone.build(native) + _check(drone.family == family, "Native %s drone identifiers must choose the matching model" % family) + var signature := _signature(drone) + _check(not drone_signatures.has(signature), "%s drones must have unique three-dimensional geometry rather than a recolor" % family) + drone_signatures[signature] = true + var duplicate := DroneModel.new() + root.add_child(duplicate) + duplicate.build(native) + _check(duplicate.body.mesh == drone.body.mesh and duplicate.body.material_override == drone.body.material_override, "Matching drones must reuse immutable body mesh and material resources") + _check(drone.body.mesh.get_aabb().size.y > 0.025, "Drone geometry must have visible depth") + drone.set_live({"deployed": true, "powered": false}) + _check(not drone.emitter.visible and not drone.thruster.visible, "Unpowered drones must not display active exhaust or emitter glow") + drone.set_live({"deployed": true, "powered": true, "firing": true, "fighting": true, "repairing": not exterior}) + _check(drone.emitter.material_override.emission_energy_multiplier == 1.5, "Only native firing or fighting must brighten the emitter") + if not exterior: + drone.animate(0.2) + _check(not is_zero_approx(drone.tool.rotation.x), "Native interior drone activity must animate its tool") + drone.set_live({"deployed": true, "dead": true}) + _check(not drone.visible, "Dead native drones must disappear") + duplicate.queue_free() + drone.queue_free() + _check(DroneModel.model_family({"is_space": false, "species": "battle", "name": "Robot de combat"}) == "battle", "Interior drone family must use native species even with a localized crew name") + _check(DroneModel.model_family({"is_space": false, "species": "repair", "name": "Board Master"}) == "repair", "A renamed interior drone must not override its authoritative native species") + var mk_one := DroneModel.new() + var mk_two := DroneModel.new() + root.add_child(mk_one) + root.add_child(mk_two) + mk_one.build({"name": "COMBAT_1", "deployed": true}) + mk_two.build({"name": "COMBAT_2", "deployed": true}) + _check(_signature(mk_one) != _signature(mk_two), "Native combat Mk II must have a visibly distinct double-barrel assembly") + mk_one.queue_free() + mk_two.queue_free() + var weapon_signatures: Dictionary = {} + for kind in ["laser", "ion", "beam", "missile", "bomb", "flak"]: + var weapon := WeaponModel.new() + root.add_child(weapon) + weapon.build({"kind": kind, "powered": true, "charge": 2, "cooldown": 2}) + var signature := _signature(weapon) + _check(not weapon_signatures.has(signature), "%s must have a distinctive weapon model" % kind) + weapon_signatures[signature] = true + _check(is_equal_approx(weapon.charge_fill.scale.x, 0.11), "Native full charge must fill the visible top-mounted charging strip") + weapon.set_live({"powered": true, "charge": 3, "cooldown": 12}) + _check(is_equal_approx(weapon.charge, 0.25) and is_equal_approx(weapon.charge_fill.scale.x, 0.0275), "Native elapsed cooldown must drive quarter charge without invented progression") + var mesh_id := weapon.charge_fill.mesh.get_instance_id() + weapon.animate(0.5) + _check(is_equal_approx(weapon.charge, 0.25) and weapon.charge_fill.mesh.get_instance_id() == mesh_id, "Weapon animation must preserve native charge and reuse its indicator mesh") + weapon.set_live({"powered": false, "charge_fraction": 0.8}) + _check(not weapon.emitter.visible and not weapon.charge_fill.visible, "Unpowered weapons must not display a charging or ready glow") + weapon.set_live({"powered": true, "firing": true}) + weapon.animate(0.01) + _check(weapon.body.position.x < 0, "%s native firing must trigger recoil" % kind) + weapon.queue_free() + var charge_weapon := WeaponModel.new() + root.add_child(charge_weapon) + charge_weapon.build({"kind": "laser", "name": "CHARGE_LASER_2", "powered": true, "charge_fraction": 0.4, "charge_max": 4, "charge_level": 2}) + _check(charge_weapon.charge_pips.size() == 4 and charge_weapon.charge_level == 2, "AE charge weapons must show actual stored shot count separately from cooldown") + _check(charge_weapon.charge_pips[0].material_override.albedo_color == Color("92e87d") and charge_weapon.charge_pips[2].material_override.albedo_color == Color("2a4245"), "Only natively stored charge pips may light up") + charge_weapon.queue_free() + var hull_bar := HullBar.new() + root.add_child(hull_bar) + hull_bar.build() + hull_bar.set_state({"hull": 7, "hull_max": 20}) + _check(hull_bar.visible and hull_bar.hull == 7 and hull_bar.hull_max == 20, "Enemy life bar must display the actual native hull values") + hull_bar.position = Vector3(1, 0.4, -1) + var viewer := Vector3(0, 1.6, 0) + hull_bar.face_viewer(viewer) + _check(hull_bar.global_basis.z.dot((viewer - hull_bar.global_position).normalized()) > 0.99, "Enemy hull plate must face the viewer rather than the floor") + hull_bar.set_state({"hull": 0, "hull_max": 20, "destroyed": true}) + _check(not hull_bar.visible, "Destroyed or absent ships must not retain a phantom enemy life bar") + hull_bar.queue_free() + # A cropped fallback texture must extrude through the same native rectangle + # as its visible deck, including a partially filled final sampling cell. + var mapped_hull := ShipModel.new() + root.add_child(mapped_hull) + var image := Image.create(10, 7, false, Image.FORMAT_RGBA8) + image.fill(Color.WHITE) + mapped_hull._make_hull_depth(image, {"x": -3, "y": 9, "w": 30, "h": 14}, Vector2.ZERO) + var mapped_multi: MultiMesh = mapped_hull.get_node("ExtrudedHull").multimesh + _check(mapped_multi.instance_count == 2, "Partial silhouette cells must retain their native extent") + # The headless dummy renderer does not retain MultiMesh transforms. Check + # their measured corner geometry, which the model keeps for shield fitting. + var first_center := (mapped_hull.shield_fit_points[0] + mapped_hull.shield_fit_points[7]) * 0.5 + var first_width: float = mapped_hull.shield_fit_points[7].x - mapped_hull.shield_fit_points[0].x + var last_width: float = mapped_hull.shield_fit_points[15].x - mapped_hull.shield_fit_points[8].x + _check(is_equal_approx(first_center.x, 7.5 / 35.0 * ShipModel.TILE) and is_equal_approx(first_center.z, 16.0 / 35.0 * ShipModel.TILE), "Voxel centers must use the deck's native texture scale and offset") + _check(is_equal_approx(first_width, 21.0 / 35.0 * ShipModel.TILE) and is_equal_approx(last_width, 9.0 / 35.0 * ShipModel.TILE), "Hull voxel widths must follow the original image mapping without extending the last cell") + mapped_hull.queue_free() + var ship := ShipModel.new() + root.add_child(ship) + ship.build("kestral", false) + _check(ship.room_nodes[0].get_meta("system_role", "") == "pilot", "Initial blueprint roles must use canonical integer room IDs from JSON") + _check(ship.hull_voxel_count > 500, "Owned ship art must produce a detailed solid 3D silhouette") + _check(ship.get_node("ExtrudedHull").multimesh.instance_count == ship.hull_voxel_count, "Hull armor must use one batched MultiMesh") + # Recorded from the current owned native Kestrel GetBaseEllipse; verify the + # presentation shell also contains the newly added depth, not only its deck. + ship._apply_shield_shape({"center": {"x": 232, "y": 175}, "a": 350, "b": 220}) + for point in ship.shield_fit_points: + _check(((point - ship.shield_center) / ship.shield_radii).length_squared() <= 1.001, "The fitted shield must contain hull and room-wall corners, including the keel") + _check(ship.shield_fit_padding <= 1.12 and ship.shield_radii.y < 1.0, "A fitted shield must cover the ship with limited footprint padding and compact height") + var room_id: int = ship.room_nodes.keys()[0] + var room_data := {"id": room_id, "oxygen": 100, "visible": true} + var crew := {"id": "model-test", "species": "engi", "name": "Test", "room": room_id, "x": 510, "y": 90, "controllable": true, "working": true, "owner": 0} + var door := {"id": 10000, "x": 400, "y": 100, "vertical": true, "open": false, "controllable": true} + var drone := {"id": "space-test", "name": "DEFENSE_1", "kind": "defense", "is_space": true, "deployed": true, "powered": true, "x": 350, "y": 200, "angle": 3.0} + ship.apply_live({"rooms": [room_data], "room_systems": {str(room_id): "shields"}, "crew": [crew], "doors": [door], "drones": [drone]}) + var room: Area3D = ship.room_nodes[room_id] + _check(room.get_node_or_null("SystemIcon") != null, "System rooms must use the user's original vanilla icons") + _check(room.get_node("SystemRole").text == "SHIELDS", "Room floor must show its installed role") + _check(room.get_node("SystemRole").font == UiAssets.font("body") and ship.crew_nodes["model-test"].get_node("Name").font == UiAssets.font("body"), "Crew names and room roles must use the owner-local native FTL font") + _check(ship.crew_nodes["model-test"].get_node("Model").animation_state == "work", "Live native manning must drive working animation") + _check(ship.crew_nodes["model-test"].get_node("Model").arms[0].rotation.x > 0.7, "A crew actor first observed during pause must receive its native activity pose immediately") + _check(ship.door_nodes["10000"].get_meta("kind") == "door", "Native door must be a selectable VR target") + _check(ship.door_nodes["10000"].position.is_equal_approx(ship.pixel_point(door, 0.13)), "Door must use native pixel center and layout offsets") + _check(ship.drone_nodes["space-test"].position.is_equal_approx(ship.pixel_point(drone, 0.3)), "Drone must use its native position in its current space") + _check(is_equal_approx(ship.drone_nodes["space-test"].rotation.y, -deg_to_rad(3.0)), "Native small drone angles must remain degrees, without a radians discontinuity") + var base_door_signature := _signature(ship.door_nodes["10000"].get_node("Left")) + door["blast"] = 1 + ship.apply_live({"rooms": [room_data], "doors": [door]}) + var armored_door_signature := _signature(ship.door_nodes["10000"].get_node("Left")) + _check(armored_door_signature != base_door_signature, "Blast-door strength must visibly add armor instead of simply recoloring") + door["blast"] = 2 + ship.apply_live({"rooms": [room_data], "doors": [door]}) + _check(_signature(ship.door_nodes["10000"].get_node("Left")) != armored_door_signature, "Improved blast doors must show another distinct armor tier") + var improved_signature := _signature(ship.door_nodes["10000"].get_node("Left")) + door["blast"] = 3 + ship.apply_live({"rooms": [room_data], "doors": [door]}) + var strong_leaf: MeshInstance3D = ship.door_nodes["10000"].get_node("Left") + _check(_signature(strong_leaf) != improved_signature, "Crew-boosted native door strength must retain a subtle distinct rail pattern") + var door_bounds := strong_leaf.mesh.get_aabb() + _check(door_bounds.size.z <= 0.03001 and door_bounds.size.x <= 0.05901 and absf(strong_leaf.position.x) + door_bounds.size.x * 0.5 <= 0.0645, "Highest-tier closed leaves must fit the original jamb and stay shallow") + var hazard_point := {"x": 510, "y": 90, "damage": 80} + room_data["fire_tiles"] = [hazard_point] + room_data["breach_tiles"] = [{"x": 545, "y": 90, "damage": 20}] + room_data["fires"] = 1 + ship.apply_live({"rooms": [room_data], "crew": [crew], "doors": [door], "drones": [drone]}) + _check(ship.room_hazards.fire_points.size() == 1 and ship.room_hazards.breach_points.size() == 1, "Native hazards must create flame and breach geometry at actual tile positions") + _check(ship.room_hazards.fire_points[0].is_equal_approx(ship.pixel_point(hazard_point, 0.082)), "Fire visuals must share the native crew/room coordinate mapping") + _check(ship.room_hazards.flame_outer.multimesh.instance_count == 1, "Hazards must use one batched mesh rather than per-tile particles and lights") + ship._process(0.1) + var closed_x: float = ship.door_nodes["10000"].get_node("Left").position.x + door["open"] = true + ship.apply_live({"rooms": [room_data], "crew": [crew], "doors": [door], "drones": [drone]}) + ship._process(0.2) + _check(ship.door_nodes["10000"].get_node("Left").position.x < closed_x - 0.05, "Native open state must slide doors apart") + ship.set_drop_target(room_id) + _check(room.get_node("Floor").material_override.albedo_color == Color(0.35, 0.95, 0.71), "Crew drop must highlight the target room") + ship.set_drop_target() + _check(room.get_node("Floor").material_override.albedo_color != Color(0.35, 0.95, 0.71), "Releasing crew must restore the room's oxygen/fire tint") + ship.simulation_paused = true + var before: float = ship.crew_nodes["model-test"].get_node("Model").animation_time + var hazard_before: float = ship.room_hazards.animation_time + var recoil_before: float = ship.weapon_nodes[0].recoil + ship._process(0.2) + _check(ship.crew_nodes["model-test"].get_node("Model").animation_time == before, "Native pause must freeze crew animations") + _check(ship.room_hazards.animation_time == hazard_before and ship.weapon_nodes[0].recoil == recoil_before, "Native pause must freeze fire, breach-air, and weapon presentation") + # Viewer facing is only deck yaw, so tilted/scaled tables keep actors upright. + ship.position = Vector3(2.0, 0.7, -1.0) + ship.rotation = Vector3(0.17, 0.7, -0.13) + ship.scale = Vector3(0.35, 0.4, 0.5) + var actor: Node3D = ship.crew_nodes["model-test"] + var actor_model: Node3D = actor.get_node("Model") + for state in ["work", "repair", "fight", "idle"]: + actor_model.set_state({"working": state == "work", "repairing": state == "repair", "fighting": state == "fight"}) + var local_view := actor.position + Vector3(1.5, 3.2, 0.8) + ship.set_viewer_position(ship.to_global(local_view)) + for i in range(90): ship._process(1.0 / 90.0) + var expected := atan2(-1.5, -0.8) + _check(absf(wrapf(actor_model.rotation.y - expected, -PI, PI)) < 0.014, "Paused %s crew must face the viewer in the transformed deck plane" % state) + _check(is_zero_approx(actor_model.rotation.x) and is_zero_approx(actor_model.rotation.z), "Viewer-facing crew must not pitch or roll as billboards") + _check(actor_model.animation_time == before, "Viewer updates must not advance native paused activity") + var yaw_before: float = actor_model.rotation.y + ship.set_viewer_position(ship.to_global(actor.position + Vector3(0, 4, 0))) + ship._process(0.1) + _check(actor_model.rotation.y == yaw_before, "A viewer directly over crew must not produce an unstable yaw") + actor_model.set_state({"running": true}) + actor.set_meta("target", actor.position + Vector3(0.25, 0, 0)) + ship._process(0.02) + _check(is_equal_approx(actor_model.rotation.y, -PI / 2.0), "Running crew must follow actual native movement instead of the viewer") + ship.rotation = Vector3.ZERO + ship.scale = Vector3.ONE + ship.apply_live({"rooms": [room_data], "room_systems": {}, "crew": [], "doors": [], "drones": []}) + _check(room.get_node_or_null("SystemRole") == null, "Empty rooms must show no meaningless floor numbers") + _check(ship.drone_nodes.is_empty() and ship.door_nodes.is_empty(), "Expired native doors/drones must be removed") + _check(ship.room_hazards.fire_points.size() == 1, "Existing native fire tiles must remain visible while paused") + room_data["fire_tiles"] = [] + room_data["breach_tiles"] = [] + room_data["fires"] = 0 + ship.apply_live({"rooms": [room_data]}) + _check(ship.room_hazards.fire_points.is_empty() and ship.room_hazards.breach_points.is_empty(), "Native repair must remove hazards immediately, even during pause") + ship.position = Vector3(2, 0.7, -1) + ship.rotation.y = 0.7 + ship.scale = Vector3(0.35, 0.4, 0.5) + var center := room.position + center.y = 0.0775 + var floor_world := ship.to_global(center) + var ray_from := floor_world + Vector3.UP + var hit := ship.room_ray_hit(ray_from, Vector3.DOWN) + _check(not hit.is_empty() and hit.room_id == room_id, "Room picking must find the actual floor on rotated and scaled ships") + _check(hit.position.is_equal_approx(floor_world) and is_equal_approx(hit.distance, 1.0), "Room ray result must preserve WORLD distance and the visible floor height") + _check(ship.pixel_point(hit.pixel, 0.0775).is_equal_approx(center), "Room floor rays must map back to exact native ship pixels") + var bounds: Rect2 = ship.room_bounds[room_id] + var beyond := Vector3(bounds.end.x + 0.015 / ship.global_basis.x.length(), 0.0775, bounds.get_center().y) + var edge_from := ship.to_global(beyond) + Vector3.UP + _check(ship.room_ray_hit(edge_from, Vector3.DOWN).is_empty(), "Strict room picking must reject rays outside the ship") + var near_edge := ship.room_ray_hit(edge_from, Vector3.DOWN, 0.02) + _check(not near_edge.is_empty() and near_edge.room_id == room_id and near_edge.edge_margin, "Friendly drop tolerance must use a small WORLD edge margin") + _check(bounds.has_point(Vector2(ship.pixel_point(near_edge.pixel).x, ship.pixel_point(near_edge.pixel).z)), "Tolerant room drops must clamp native commands back inside the selected room") + _check(ship.room_ray_hit(edge_from + ship.global_basis.x.normalized() * 0.03, Vector3.DOWN, 0.02).is_empty(), "Room drop tolerance must reject positions beyond its WORLD margin") + _check(ship.room_ray_hit(ray_from, Vector3.RIGHT, 0.02).is_empty(), "Parallel floor rays must not pick unrelated rooms") + for asset in ["auto_assault", "fed_scout__fed_scout_pirate", "rebel_long", "energy_fighter_pirate"]: + var opponent := ShipModel.new() + root.add_child(opponent) + opponent.build(asset, true) + opponent.inspect_rooms = true + var id: int = opponent.room_nodes.keys()[0] + # Native geometry is authoritative, including enemy layout alignment + # and any layout x/y offsets already applied to ship pixel coordinates. + var native_shape := {"center": {"x": 110, "y": 160}, "a": 200, "b": 270} + opponent.apply_live({"rooms": [{"id": id, "visible": false, "oxygen": 0, "fire_tiles": [hazard_point], "breach_tiles": [hazard_point]}], "room_systems": {str(id): "weapons"}, "crew": [], "shield_shape": native_shape, "shield": 2}) + var native_center: Vector3 = opponent.pixel_point(native_shape.center, opponent.shield_center.y) + _check(opponent.shield_center.is_equal_approx(native_center), "%s must use the native ellipse center, without adding image alignment twice" % asset) + _check(is_equal_approx(opponent.shield_radii.x, 200.0 / 35.0 * opponent.TILE * opponent.shield_fit_padding) and is_equal_approx(opponent.shield_radii.z, 270.0 / 35.0 * opponent.TILE * opponent.shield_fit_padding), "%s must derive its shield semiaxes from native geometry with measured visual padding" % asset) + _check(opponent.shield_fit_padding >= 1.035 and opponent.shield_fit_padding <= 1.12, "Shield footprint fitting must remain limited instead of hiding coordinate errors") + _check(opponent.room_nodes[id].get_node("SystemIcon").visible and opponent.room_nodes[id].get_node("SystemRole").visible, "Enemy utility icons must remain visible for targeting under sensor fog") + _check(opponent.room_hazards.fire_points.is_empty() and opponent.room_hazards.breach_points.is_empty(), "Hidden enemy rooms must not reveal hazard tile positions through sensor fog") + _check(opponent.room_hazards.flame_outer.multimesh.mesh == ship.room_hazards.flame_outer.multimesh.mesh, "Hazards across ships must reuse immutable mesh resources") + opponent.queue_free() + print("MODEL_TESTS ", "PASS" if failures == 0 else "FAIL %d" % failures, " species=8 animations=4 weapon_types=6 drone_families=10 deck_facing=paused+transformed door_tiers=4 hull_voxels=", ship.hull_voxel_count) + ship.queue_free() + await process_frame + quit(0 if failures == 0 else 1) diff --git a/tools/test_ui.gd b/tools/test_ui.gd new file mode 100644 index 0000000..de12f51 --- /dev/null +++ b/tools/test_ui.gd @@ -0,0 +1,70 @@ +extends SceneTree + +var failures := 0 +var content_updates := 0 + + +func check(condition: bool, message: String) -> void: + if not condition: + failures += 1 + push_error(message) + + +func _initialize() -> void: + call_deferred("run") + + +func run() -> void: + var camera := Camera3D.new() + camera.position = Vector3(0, 0, 3) + camera.current = true + root.add_child(camera) + var surface: Node3D = load("res://scripts/hud_surface.gd").new() + surface.frame_file = "ui-test-frame.png" + root.add_child(surface) + surface.set_process(false) + surface.canvas.texture_changed.connect(func(): content_updates += 1) + var image := Image.create(1280, 720, false, Image.FORMAT_RGBA8) + image.fill(Color(0.2, 0.6, 0.3, 1.0)) + image.save_png("res://local_game_data/ui-test-frame.png") + surface.canvas.poll_original_frame() + var texture: Texture2D = surface.canvas.original_frame + for i in range(4): await process_frame + await RenderingServer.frame_post_draw + var initial_updates := content_updates + check(surface.viewport.render_target_update_mode == SubViewport.UPDATE_ONCE, "HUD must use one-shot viewport rendering, rather than per-frame rendering") + surface.set_state({"paused": true}) + surface.canvas.poll_original_frame() + check(content_updates == initial_updates, "Unchanged native PNGs and live state must not schedule another native HUD render") + image.fill(Color(0.7, 0.2, 0.3, 1.0)) + image.save_png("res://local_game_data/ui-test-frame.png") + surface.canvas.poll_original_frame() + check(surface.canvas.original_frame == texture, "Native PNG updates must reuse the existing GPU texture") + check(surface.viewport.render_target_update_mode == SubViewport.UPDATE_ONCE, "Fresh native pixels must schedule one viewport render") + check(surface.canvas.frame_image.get_pixel(0, 0).r > 0.6, "The refreshed image must also update picking pixels") + for i in range(3): await process_frame + await RenderingServer.frame_post_draw + check(surface.viewport.get_texture().get_image().get_pixel(0,0).r > 0.6, "A second one-shot render must update the actual GPU viewport texture") + var before_crop := content_updates + surface.set_crop(Rect2(0, 0, 1280, 720), Vector2(2.85, 1.603125)) + check(content_updates == before_crop, "An identical crop must not schedule another render of a static hand screen") + surface.set_crop(Rect2(300, 90, 650, 520), Vector2(1.02, 0.816)) + check(content_updates == before_crop + 1, "A real native panel crop change must render the new bounds once") + var keyboard: Node3D = load("res://scripts/rename_keyboard.gd").new() + root.add_child(keyboard) + keyboard.set_entry({"active":true,"id":"ui-test","kind":"crew","text":"Dana","cursor":2}) + check(keyboard.visible and keyboard.value_label.text == "Da|na", "Native cursor and current name must display in the VR keyboard") + for i in range(3): await process_frame + await RenderingServer.frame_post_draw + keyboard.viewport.get_texture().get_image().save_png("res://local_game_data/rename-keyboard.png") + var result: Dictionary = keyboard.activate({"op":"shift"}) + check(result.is_empty() and not keyboard.upper, "Shift must change keyboard case without sending a game hotkey") + result = keyboard.activate({"op":"confirm"}) + check(result.op == "confirm" and result.entry_id == "ui-test", "Done must submit the exact active native text field") + keyboard.set_entry({"active":false}) + check(not keyboard.visible and keyboard.ray_hit(Vector3(0,0,1), Vector3.FORWARD).is_empty(), "An inactive keyboard must disappear and stop consuming controller rays") + surface.queue_free() + keyboard.queue_free() + await process_frame + print("UI_TESTS ", "PASS" if failures == 0 else "FAIL %d" % failures) + quit(0 if failures == 0 else 1)