diff --git a/.github/CODE_OF_CONDUCT.md b/.github/CODE_OF_CONDUCT.md new file mode 100644 index 00000000000..2d8cb1bccb6 --- /dev/null +++ b/.github/CODE_OF_CONDUCT.md @@ -0,0 +1,27 @@ +# Code of Conduct + +## Our Standard + +OFP is a technical community. Participation is expected to be substantive, good-faith, and respectful. We are here to build software together. + +Specifically: + +- Engage with ideas, not people. Critique code and proposals, not contributors. +- Document your reasoning. "No" without explanation is not a useful contribution. +- Assume good faith until evidence says otherwise. +- Recuse yourself when you have a personal stake in a decision. + +## What is not acceptable + +- Personal attacks, harassment, or sustained hostility toward any contributor. +- Blocking contributions without documented technical reasoning. +- Using contributor access to serve personal or project interests at the expense of the community queue. +- Conduct that would make a reasonable person unwilling to participate. + +## Reporting + +Reports go to the active Maintainers via GitHub Discussion (public) or direct contact (private, if necessary). Maintainers respond with documented reasoning. A Maintainer who is the subject of a report recuses themselves from its handling. + +## Enforcement + +Consequences scale with severity and pattern: documented warning, temporary suspension of merge/approve authority, removal from the contributor ladder. All actions are documented publicly unless privacy requires otherwise. diff --git a/.github/DISCUSSION_TEMPLATE/feature_request.yml b/.github/DISCUSSION_TEMPLATE/feature_request.yml new file mode 100644 index 00000000000..be469709327 --- /dev/null +++ b/.github/DISCUSSION_TEMPLATE/feature_request.yml @@ -0,0 +1,21 @@ +title: "[Feature Request] " +labels: ["feature-request"] +body: | + ## Problem Statement + + What problem does this solve? Describe the gap, limitation, or friction you or others are experiencing. + Be specific - who encounters this, and when? + + ## Proposed Solution + + What would you like to see implemented? Describe the behavior, not the implementation. + If you have implementation ideas, include them, but the problem statement is what gets the request accepted. + + ## Mod and Player Impact + + Which mods, campaigns, or players would benefit from this? + Links to existing discussions, forum threads, or community requests that support this are helpful here. + + ## Additional Context + + Anything else relevant - prior art in FSO or other engines, technical constraints you're aware of, related requests. diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 00000000000..a595ebaf4b5 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,57 @@ +name: Bug Report +description: Report a bug in the OFP engine build. Feature requests belong in Discussions, not here. +labels: ["bug"] +body: + - type: markdown + attributes: + value: | + **This tracker is for bugs only.** If something isn't broken (it's a feature request, a question, or a proposal) please open a [Discussion](../../discussions) instead. + + - type: textarea + id: description + attributes: + label: What happened? + description: A clear description of the bug. + placeholder: Describe what went wrong. + validations: + required: true + + - type: textarea + id: reproduction + attributes: + label: Steps to reproduce + description: How do we make this happen? + placeholder: | + 1. Launch with mod X + 2. Load mission Y + 3. ... + validations: + required: true + + - type: textarea + id: expected + attributes: + label: Expected behavior + description: What should have happened? + validations: + required: true + + - type: textarea + id: environment + attributes: + label: Environment + description: OFP build version, OS, and any relevant mod or configuration details. + placeholder: | + OFP version: + OS: + Mods active: + validations: + required: true + + - type: textarea + id: logs + attributes: + label: Logs or screenshots + description: Attach fs2_open.log or relevant screenshots if applicable. + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 00000000000..57d68a11cda --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,8 @@ +blank_issues_enabled: false +contact_links: + - name: Feature Request + url: https://github.com/ofp-fs2open/fs2open.github.com/discussions/new?category=feature-requests + about: Feature requests and proposals belong in Discussions, not Issues. + - name: General Discussion + url: https://github.com/ofp-fs2open/fs2open.github.com/discussions + about: Questions, roadmap discussion, and development conversation. diff --git a/.github/workflows/build-release.yaml b/.github/workflows/build-release.yaml index 8e73f835aa3..3f8eda40eb2 100644 --- a/.github/workflows/build-release.yaml +++ b/.github/workflows/build-release.yaml @@ -9,6 +9,8 @@ jobs: create_release: name: Create GitHub release runs-on: ubuntu-latest + permissions: + contents: write # required to create the GitHub release (repo default token is read-only) steps: - name: Create Release id: create_release @@ -30,7 +32,8 @@ jobs: tag_name: ${{ github.ref }} release_name: Release ${{ github.ref }} draft: false - prerelease: false + # Any tag with a - suffix (RCs, date-stamped nightlies like 26_1_0-20260713) is a prerelease; a clean release_MM_mm_bb is a full release + prerelease: ${{ contains(github.ref_name, '-') }} build_linux: strategy: @@ -98,6 +101,8 @@ jobs: needs: build_linux runs-on: ubuntu-latest container: ghcr.io/scp-fs2open/sftp_upload:sha-ab87451 + permissions: + contents: write # required to publish assets to the GitHub release strategy: matrix: arch: [x86_64, arm64] @@ -273,6 +278,8 @@ jobs: needs: build_windows runs-on: ubuntu-latest container: ghcr.io/scp-fs2open/sftp_upload:sha-ab87451 + permissions: + contents: write # required to publish assets to the GitHub release strategy: fail-fast: false # Run the other jobs in the matrix instead of failing them matrix: @@ -412,6 +419,8 @@ jobs: needs: build_mac runs-on: ubuntu-latest container: ghcr.io/scp-fs2open/sftp_upload:sha-ab87451 + permissions: + contents: write # required to publish assets to the GitHub release strategy: matrix: arch: [x86_64, arm64] diff --git a/.github/workflows/post-build-release.yaml b/.github/workflows/post-build-release.yaml index abe064721fc..d860b6fc6bc 100644 --- a/.github/workflows/post-build-release.yaml +++ b/.github/workflows/post-build-release.yaml @@ -27,17 +27,15 @@ on: env: GITHUB_REPO: ${{ github.repository }} # repo this workflow was called from - INDIEGAMES_USER: ${{ secrets.INDIEGAMES_USER }} - INDIEGAMES_SSHPASS: ${{ secrets.INDIEGAMES_PASSWORD }} NEBULA_USER: ${{ secrets.NEBULA_USER }} NEBULA_PASSWORD: ${{ secrets.NEBULA_PASSWORD }} - HLP_API: ${{ secrets.HLP_API }} - HLP_KEY: ${{ secrets.HLP_KEY }} + NEBULA_PRIVATE: ${{ vars.NEBULA_PRIVATE }} # 'false' publishes publicly; unset/anything else = private + NEBULA_USE_URLS: ${{ vars.NEBULA_USE_URLS }} # 'true' registers GitHub URLs (needs URLS_FOR); else upload to Nebula RELEASE_TAG: ${{ inputs.releaseTag }} jobs: post_builds: - name: Post builds on Nebula and the forums + name: Post builds on Nebula runs-on: ubuntu-latest steps: - name: Caller @@ -62,12 +60,10 @@ jobs: WINDOWS_RESULT: ${{ inputs.windows_result }} MAC_RESULT: ${{ inputs.mac_result }} GITHUB_REPO: ${{ github.repository }} # repo this workflow was called from - INDIEGAMES_USER: ${{ secrets.INDIEGAMES_USER }} - INDIEGAMES_SSHPASS: ${{ secrets.INDIEGAMES_PASSWORD }} NEBULA_USER: ${{ secrets.NEBULA_USER }} NEBULA_PASSWORD: ${{ secrets.NEBULA_PASSWORD }} - HLP_API: ${{ secrets.HLP_API }} - HLP_KEY: ${{ secrets.HLP_KEY }} + NEBULA_PRIVATE: ${{ vars.NEBULA_PRIVATE }} + NEBULA_USE_URLS: ${{ vars.NEBULA_USE_URLS }} RELEASE_TAG: ${{ inputs.releaseTag }} run: python ci/post/main.py release diff --git a/COMMUNITY_QUEUE.md b/COMMUNITY_QUEUE.md new file mode 100644 index 00000000000..51a86866583 --- /dev/null +++ b/COMMUNITY_QUEUE.md @@ -0,0 +1,50 @@ +# Community Queue + +This is the canonical list of Queue-Accepted feature requests, ordered oldest-accepted first. + +Contributors implementing features should draw from this list, working from the top down. If you skip an item, document why in your PR description. + +--- + +## How to read this list + +| Position | Discussion | Accepted | Status | +|----------|------------|----------|--------| +| 1 | [Title](#) | YYYY-MM-DD | Open | + +**Status values:** +- **Open** — Available for any contributor to pick up +- **In Progress** — Being actively worked; link to PR in the Discussion thread +- **Merged** — Done. Will be removed from the active queue and noted in the relevant release milestone. + +--- + +## When skipping ahead is fine + +Working top-down is the default, not a rule. Picking from anywhere in the queue is welcome when you have a good reason — just note it briefly in your PR description. Common acceptable reasons: + +- **Scale matches your availability.** You have a focused window (e.g. a few free days this week) and a lower-priority item is the right size to finish in that window, where a higher-priority item is not. Better to land a smaller item cleanly than to start a multi-day effort you can't see through. +- **Mismatched skillset.** You're a graphics person and the top item is a netcode rewrite. Doing what you're good at lands faster and at higher quality than grinding through unfamiliar code. +- **Code locality.** You're already deep in a subsystem for another PR and a lower queue item touches the same files. Bundling avoids re-paging-in the context later. +- **Unblocks a higher-priority item.** A lower item is a real prerequisite for one above it. Doing it out of order is the correct order. +- **New-contributor warmup.** First-time contributors picking a smaller item to learn the build, codebase conventions, and PR process before tackling something larger. +- **External coordination.** A mod team or release is blocked on a specific item that isn't at the top. + +--- + +## Active Queue + +*The queue populates as feature requests are reviewed and accepted. Check [Feature Requests](../discussions?category=feature-requests) for pending requests under review.* + +--- + +## Notes for contributors + +- Pick up an Open item by commenting in its Discussion thread that you are working on it. +- Update the Discussion thread with a link to your PR when you open one. +- If you need to abandon an item, say so in the thread so it returns to Open status. +- Questions about queue ordering or item eligibility go in the Discussion thread for that item, or in [General Development](../discussions?category=general-development). + +--- + +*This list is maintained by OFP Maintainers. Last updated: see edit history.* diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000000..7ac2090f697 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,94 @@ +# Contributing to the Open FreeSpace Project + +Welcome. OFP is a community-governed fork of FreeSpace Open. This document covers how to contribute code, how the Community Queue works, and what to expect from the review and merge process. + +--- + +## Before You Start + +- **Bug fixes** are always welcome. Open a PR, get it merged. No prerequisites. +- **Feature work** should draw from the Community Queue (see below). Personal features are allowed but subject to reviewer prioritization. +- All contributions must be compatible with FSO licensing terms. New OFP-originated contributions are released under the Unlicense. + +--- + +## The Community Queue + +Feature requests live in [GitHub Discussions](../../discussions). The Community Queue is the ordered list of accepted, active feature requests that contributors draw from when choosing what to implement. + +### How a request enters the Queue + +1. Open a Discussion using the Feature Request template (problem statement, proposed solution, mod/player impact). +2. The community engages — upvotes, comments, refinements. +3. A Reviewer or Maintainer marks it **Queue-Accepted** when it meets the bar: clearly defined, technically feasible, and demonstrably wanted by currently active community members. +4. It enters the Queue ordered by the date it was accepted. + +### Working the Queue + +Draw from the Queue **oldest-accepted first**. If you skip ahead, document why in your PR description. Acceptable reasons are narrow: you lack the technical background for the oldest items, or your expertise is specifically needed elsewhere. Maintainers may push back if skips become a pattern. + +### Stale requests + +A request can be marked **Stale** if the original author and supporters have been inactive for over a year with no meaningful recent engagement. Stale requests move to a Stale Discussions category and can be reactivated by any active community member. Reactivated requests re-enter the Queue at the back. + +--- + +## Code Review Matching + +OFP operates on community reciprocity. Review bandwidth is a shared resource. Contributors who consistently participate in reviews or Queue work may be prioritized when Maintainer bandwidth is limited. + +Community contribution means any combination of: +- Meaningful code reviews on other contributors' PRs +- Implementing features from the Community Queue + +There is no quota. Two Queue implementations and zero reviews is fine. Zero Queue implementations and several substantive reviews is also fine. Maintainers exercise judgment publicly and with documented reasoning. + +**Bug fixes are always exempt.** + +--- + +## Contributor Ladder + +### Contributor +**Eligibility:** One or more merged PRs. +**How:** Self-nominate or be nominated in a public GitHub Discussion. Approved by Maintainer consensus. +**Authority:** None beyond submitting PRs. + +### Reviewer +**Eligibility:** 5 or more merged PRs, plus meaningful participation in at least 3 code reviews on other contributors' work. +**How:** Self-nominate or be nominated in a public GitHub Discussion. Approved by Maintainer consensus. +**Authority:** Can approve PRs. Cannot merge. + +### Maintainer +**Eligibility:** Active Reviewer and code contributor for at least 6 months, demonstrated consistent good judgment in reviews, and active participation in RFC discussions. +**How:** Self-nominate or be nominated in a public GitHub Discussion. Approved by Maintainer consensus. +**Authority:** Can merge PRs. Responsible for Queue management and RFC rulings. + +Good judgment means: reviews that catch real issues, RFC feedback that is constructive and technically grounded, no pattern of blocking contributions without documented reasoning. The public record is the evidence. + +### Removal + +Any tier can be lost for inactivity (12 months with no activity, 30-day notice) or through a documented conduct or judgment process. The process is the same as promotion: public, reasoned, and on the record. + +### Recusal + +A Maintainer or Reviewer with a direct personal stake in a decision recuses themselves and says so publicly. Direct personal stake means: the PR or RFC primarily serves their own mod or project, there is documented ongoing conflict with the contributor involved, or they authored the code under review. + +A recused Maintainer may still comment technically but does not vote or merge. If recusal leaves fewer than two Maintainers able to rule, the decision opens to a community vote in the relevant Discussion thread. Any active Contributor or above may vote. + +--- + +## Submitting a PR + +1. Fork the repo and create a branch from `main`. +2. Write clear commit messages. +3. If implementing a Queue item, reference the Discussion in your PR description. +4. If skipping a Queue item, explain why in your PR description. +5. Ensure CI passes on Linux, Windows, and macOS before requesting review. +6. Request review from a Reviewer or Maintainer. + +--- + +## Code of Conduct + +See [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md). diff --git a/GOVERNANCE.md b/GOVERNANCE.md new file mode 100644 index 00000000000..b7baf0f8271 --- /dev/null +++ b/GOVERNANCE.md @@ -0,0 +1,50 @@ +# OFP Governance + +This document describes how the Open FreeSpace Project makes decisions, manages its contributor ladder, and handles conflict. + +--- + +## Principles + +- Trust and authority are earned through demonstrated contribution, not granted socially. +- All decisions of consequence are made publicly and with documented reasoning. +- The Community Queue represents accumulated community need. It can be thought of as a roadmap. + +--- + +## Decision Making + +Most decisions happen in the open: PR reviews, Discussion threads, RFC discussions. The default process is lazy consensus. If no one objects with a documented reason within a reasonable window, the decision stands. + +Contested decisions escalate to a Maintainer vote in the relevant Discussion thread. Maintainers vote publicly. Majority rules. Ties go to community vote (any active Contributor or above may participate). + +--- + +## RFCs + +Significant architectural changes, governance amendments, or anything that would affect the mod ecosystem broadly requires an RFC (Request for Comments) opened as a GitHub Discussion. RFCs stay open for a minimum of two weeks before a ruling. Maintainers are expected to participate and document their reasoning. + +--- + +## Release Process + +OFP targets a regular release cadence. Proposed timelines are posted as GitHub Discussions at least six weeks before a feature freeze. Community members may comment on scope, timing, and what gets held versus shipped. Maintainers make final calls but explain decisions that go against stated community preference. + +Releases include: +- Features from the Community Queue that are merged and stable +- Bug fixes +- Upstream FSO merges that have passed integration testing + +--- + +## Upstream Sync + +OFP merges from FSO upstream weekly. Maintainers may defer a specific week's merge if upstream is mid-refactor, with a note posted in the sync thread. Deferred merges are picked up the following week. + +Changes that conflict with OFP's architectural decisions are evaluated case by case. The decision to accept, adapt, or exclude an upstream change is documented publicly in the sync thread. + +--- + +## Amending This Document + +Governance changes require an RFC with at least two weeks of open comment and consensus among active Maintainers. diff --git a/Readme.md b/Readme.md index 5126987f2b4..9e66e9bf398 100644 --- a/Readme.md +++ b/Readme.md @@ -1,9 +1,39 @@ -FreeSpace2 *S*ource *C*ode *P*roject -== -[![Coverity](https://img.shields.io/coverity/scan/870.svg)](https://scan.coverity.com/projects/870) +# Open FreeSpace Project -Building --- -Before you do anything, make sure you have updated your git submodules, either by running `git submodule update --init --recursive` or by cloning the repository with the `--recursive` flag.
+OFP is a community-governed fork of [FreeSpace Open](https://github.com/scp-fs2open/fs2open.github.com), maintained by and for the active FreeSpace modding and playing community. -The main instructions for building can be found at our github wiki, on the [Building](https://github.com/scp-fs2open/fs2open.github.com/wiki/Building) page. \ No newline at end of file +## What OFP is + +- A fork of FSO with weekly upstream merges, keeping compatibility with the broader mod ecosystem +- A transparent development platform where community needs drive the roadmap +- An alternative for contributors who want more direct participation in engine direction + +OFP is not a replacement for FSO. It is a parallel platform with different governance. + +## What is different + +- Feature requests are tracked in a public [Community Queue](https://github.com/ofp-fs2open/fs2open.github.com/discussions) worked oldest-first +- Contributor trust is earned through a documented, public ladder +- All decisions (merges, queue management, RFC rulings) are made publicly with documented reasoning +- Bug fixes are always merged without prerequisites + +## Getting started + +- **Found a bug?** [Open an issue](../../issues/new/choose) +- **Have a feature request?** [Start a Discussion](../../discussions/new?category=feature-requests) +- **Want to contribute code?** Read [CONTRIBUTING.md](CONTRIBUTING.md) +- **Want to understand governance?** Read [GOVERNANCE.md](GOVERNANCE.md) + +## Building + +Build instructions follow FSO's existing documentation. CI runs on Linux, Windows, and macOS. All three platforms are first-class. + +Builds are published to [GitHub Releases](../../releases) and available on Nebula as a registered alternate build dependency. + +## Licensing + +All FSO licensing terms are preserved. New OFP-originated contributions are released under the Unlicense. + +## Upstream relationship + +OFP merges from FSO upstream weekly. Sync activity is tracked in a dedicated Discussion thread. Changes that conflict with OFP's architectural decisions are evaluated case by case and documented publicly. diff --git a/ci/post/file_list.py b/ci/post/file_list.py index 980d0ea5fbf..c63e702abd2 100644 --- a/ci/post/file_list.py +++ b/ci/post/file_list.py @@ -149,7 +149,8 @@ def execute_request(path): # x64 is the Visual Studio name but for consistency we need Win64 if platform == "x64": platform = "Win64" - elif platform == "arm64": + elif platform.lower() == "arm64": + # Windows ARM packages are named "ARM64" (from the build arch); map to the Nebula key platform = "WinARM64" binary_files.append(ReleaseFile(name, url, platform, group_match.group(3))) diff --git a/ci/post/installer.py b/ci/post/installer.py index 5473f980731..95053b9019a 100644 --- a/ci/post/installer.py +++ b/ci/post/installer.py @@ -37,7 +37,7 @@ def _gen_hash(fileobj, hash_alg): return h.hexdigest() -def get_hashed_file_list(file: ReleaseFile, session: requests.Session = None, hash_alg: str = "sha256"): +def get_hashed_file_list(file: ReleaseFile, session: requests.Session = None, hash_alg: str = "sha256", after_hash=None): if session is None: session = requests.Session() @@ -79,6 +79,12 @@ def get_hashed_file_list(file: ReleaseFile, session: requests.Session = None, ha file.content_hashes = hash_list + # Reuse this single download for anything else that needs the bytes (e.g. uploading + # the archive into Nebula's storage). local_file is a seekable binary stream. + if after_hash is not None: + local_file.seek(0) + after_hash(file, local_file) + def render_installer_config(version, groups, config): template = Template(filename=config["templates"]["installer"], module_directory='/tmp/mako_modules') diff --git a/ci/post/main.py b/ci/post/main.py index fb7c0d7b7be..9bc2d64bd25 100644 --- a/ci/post/main.py +++ b/ci/post/main.py @@ -1,5 +1,5 @@ # Post-build python script -# Uploads build packages to Nebula and creates a forum post on https://www.hard-light.net +# Uploads build packages to Nebula # Uses the following environment variables (Set by the github action workflow before execution) # FSO versioning @@ -7,17 +7,10 @@ # FSO_VERSION_MINOR # FSO_VERSION_BUILD -# Credentials into IndieGames file host -# INDIEGAMES_USER: -# INDIEGAMES_SSHPASS: - # Credentials into Nebula Mod Database # NEBULA_USER: # NEBULA_PASSWORD: - -# Credentials into hard-light.net for forum post -# HLP_API: -# HLP_KEY: +# NEBULA_PRIVATE: "false" publishes publicly; anything else (or unset) uploads privately # LINUX_RESULT: bool, if the linux builds were successfully uploaded # WINDOWS_RESULT: bool, if the windows builds were successfully uploaded @@ -35,7 +28,6 @@ import file_list import installer import nebula -import forum # Compile regexes for extracting version components MAJOR_VERSION_PATTERN = re.compile("(?:set_if_not_defined|set)\(FSO_VERSION_MAJOR (\d+)\)") @@ -76,48 +68,28 @@ def _match_version_str(text, regex): def get_source_version(date_version: datetime, tag_name: str) -> semantic_version.Version: - """! Retrieves the version string from version_override.cmake (and version.cmake) + """! Derives the release version from the git tag. - @param[in] `date_version` Date this build is published for release - @param[in] `tag_name` Tag of this release, used to determine if release, rc, or nightly + @param[in] `date_version` Unused; kept for signature compatibility. + @param[in] `tag_name` Release tag, e.g. release_26_1_0-20260713 - @return If release: `FSO_VERSION_MAJOR.FSO_VERSION_MINOR.FSO_VERSION_BUILD` - @return If rc: `FSO_VERSION_MAJOR.FSO_VERSION_MINOR.FSO_VERSION_BUILD-FSO_VERSION_REVISION_STR` - @return If nightly: `FSO_VERSION_MAJOR.FSO_VERSION_MINOR.FSO_VERSION_BUILD-FSO_VERSION_REVISION_STR` (uses both version_override.cmake and version.cmake) + @return The version parsed from the tag: release_MAJOR_MINOR_BUILD[-SUFFIX] becomes + MAJOR.MINOR.BUILD[-SUFFIX] (e.g. release_26_1_0-20260713 -> 26.1.0-20260713). - @note This is the version used to identify in Nebula and the forums. version_override.cmake is used for the builds - made by CI tools and version.cmake is used for builds made by devs. + @note OFP derives the Nebula version from the tag rather than from a generated + version_override.cmake (which OFP's manual tagging does not produce). """ - - with open(os.path.join("..", "..", "version_override.cmake"), "r") as f: - filetext = f.read() - major = _match_version_number(filetext, MAJOR_VERSION_PATTERN) - minor = _match_version_number(filetext, MINOR_VERSION_PATTERN) - build = _match_version_number(filetext, BUILD_VERSION_PATTERN) - # revision = _match_version_number(filetext, REVISION_VERSION_PATTERN) - revision_str = _match_version_str(filetext, REVISION_STR_VERSION_PATTERN) - - print("Parsing version_override.cmake...") - if "rc" in tag_name.lower(): - version = semantic_version.Version("{}.{}.{}-{}".format(major, minor, build, revision_str)) - - elif "release" in tag_name.lower(): - version = semantic_version.Version("{}.{}.{}".format(major, minor, build)) - - elif "nightly" in tag_name.lower(): - # Nightly's version_override.cmake only has FSO_VERSION_REVISION and FSO_VERSION_REVISION_STR set - # So, we have to pull the rest of the version string from version.cmake - print("Parsing version.cmake...") - with open(os.path.join("..", "..", "cmake", "version.cmake"), "r") as f: - major = _match_version_number(filetext, MAJOR_VERSION_PATTERN) - minor = _match_version_number(filetext, MINOR_VERSION_PATTERN) - build = _match_version_number(filetext, BUILD_VERSION_PATTERN) - version = semantic_version.Version("{}.{}.{}-{}".format(major, minor, build, revision_str)) - - else: + m = re.match(r"^(?:release|nightly)_(.+)$", tag_name) + if not m: print(" ERROR: malformed tag_name %s" % tag_name) sys.exit(1) + numeric, _, suffix = m.group(1).partition("-") + ver = numeric.replace("_", ".") + if suffix: + ver = "{}-{}".format(ver, suffix) + + version = semantic_version.Version(ver) print(" version: {}".format(version)) print(" version.prerelease: {}".format(version.prerelease)) return version @@ -131,33 +103,16 @@ def main(): "github": { "repo": os.environ["GITHUB_REPO"], }, - "ftp": { - "host": "scp.indiegames.us", - "user": os.environ["INDIEGAMES_USER"], - "pass": os.environ["INDIEGAMES_SSHPASS"], - "path": "public_html/builds/{type}/{version}/", - "mirrors": ( - "https://perses.feralhosting.com/datacorder/builds/{type}/{version}/{file}", - "https://scp.indiegames.us/builds/{type}/{version}/{file}", - ), - }, "nebula": { "user": os.environ["NEBULA_USER"], "password": os.environ["NEBULA_PASSWORD"], - }, - "hlp": { - "api": os.environ["HLP_API"], - "key": os.environ["HLP_KEY"], - }, - "templates": { - "nightly": "nightly.mako", - "release": "release.mako", - }, - "nightly": { - "hlp_board": 173, - }, - "release": { - "hlp_board": 50, + # OFP: releases upload to Nebula privately by default; set repo variable + # NEBULA_PRIVATE=false to publish publicly (which also fires Nebula's announcement). + "private": os.environ.get("NEBULA_PRIVATE", "true").strip().lower() != "false", + # Option A (register GitHub URLs) needs Nebula's URLS_FOR allowlist. Default off: + # upload the archives into Nebula storage instead (Option B). Set NEBULA_USE_URLS=true + # once the account is allowlisted. + "use_urls": os.environ.get("NEBULA_USE_URLS", "false").strip().lower() == "true", }, } @@ -230,8 +185,26 @@ def main(): files, sources = file_list.get_release_files(tag_name, config) print("Hashing files...") - for file in files: - installer.get_hashed_file_list(file) + if config["nebula"].get("use_urls"): + # Option A: archives stay on GitHub; we only register their URLs (needs URLS_FOR). + for file in files: + installer.get_hashed_file_list(file) + else: + # Option B: upload each archive into Nebula storage (no URLS_FOR needed), reusing + # the single download that content-hashing already performs. + print("Uploading build archives to Nebula...") + upload_session, upload_token = nebula.login_session(config) + if not upload_token: + print("ERROR: Nebula login failed; cannot upload archives.") + sys.exit(1) + + def _upload_after_hash(f, local_file): + if not nebula.upload_chunked(upload_session, upload_token, local_file, f.hash, f.size): + print("ERROR: failed to upload %s to Nebula." % f.name) + sys.exit(1) + + for file in files: + installer.get_hashed_file_list(file, after_hash=_upload_after_hash) # Construct the file groups # split the file list into a dictionary of {group: files} pairs using `groupby()` @@ -270,37 +243,20 @@ def main(): # Publish release builds to Nebula print("Publishing to Nebula...") + if not version.prerelease: + stability = "stable" + elif any("rc" in str(part).lower() for part in version.prerelease): + stability = "rc" + else: + stability = "nightly" nebula.submit_release( - nebula.render_nebula_release(version, "rc" if version.prerelease else "stable", files, config), + nebula.render_nebula_release(version, stability, files, config), config) - # Publish forum post, using the release.mako template - print("Publishing to Hard-light.net...") - fapi = forum.ForumAPI(config) - fapi.post_release(date.strftime(DATEFORMAT_FORUM), version, groups, sources) - else: - # Dead for now, nightly publish is done elsewhere - # Nightly specific action - print("Retrieving file list...") - files = file_list.get_ftp_files("nightly", tag_name, config) - - print("Hashing files...") - for file in files: - installer.get_hashed_file_list(file) - - print("Publishing to Nebula...") - nebula.submit_release( - nebula.render_nebula_release(version, "nightly", files, config), - config) - - # Compile commit messages into a log with all commits between this nightly and the previous nightly - log = check_output(("git", "log", "%s^..%s^" % (previous_tag, tag_name), "--no-merges", "--stat", "--pretty=format:\"%s\"" % LOG_FORMAT.strip())) - - # Publish forum post, using the nighty.mako template - print("Publishing to Hard-light.net...") - fapi = forum.ForumAPI(config) - fapi.post_nightly(date.strftime(DATEFORMAT_FORUM), os.environ["GITHUB_SHA"], files, log, (linux_success and windows_success)) + # OFP does not publish nightlies through this script. Nightly builds are handled + # separately, and this path previously relied on SCP-specific FTP/forum infra. + print("Nightly publishing via ci/post is not used by OFP; nothing to do.") print("Done!") diff --git a/ci/post/nebula.py b/ci/post/nebula.py index 792fc33f576..1fa7188c975 100644 --- a/ci/post/nebula.py +++ b/ci/post/nebula.py @@ -1,5 +1,6 @@ import os.path import sys +import hashlib import traceback import requests @@ -19,19 +20,19 @@ metadata = { 'type': 'engine', - 'title': 'FSO', + 'title': 'OFP', 'notes': '', - 'banner': 'https://fsnebula.org/storage/0d/e7/bf64bcdea9a9c115969cfb784e1ca457d24a7c2da4fc6f213521c3bb6abb.png', + 'banner': '17c6709138d5a8959c5605a1d6f465d6c1a0dada9881f5b905c01336848bb8de', 'screenshots': [], 'videos': [], - 'first_release': '2017-09-24', + 'first_release': '2026-07-12', 'cmdline': '', - 'mod_flag': ['FSO'], - 'tile': 'https://fsnebula.org/storage/ec/cc/0bf23e028c26d5175ff52d003bff85b0a17b0ddfc1130d65bdf6d36f6324.png', + 'mod_flag': ['OFP'], + 'tile': '12372e28d4654659b3f3a38f52b627a4ad37fb0af4fef63fa9dafa6afe5a27db', 'logo': None, 'release_thread': None, 'description': '', - 'id': 'FSO', + 'id': 'OFP', 'packages': [], } @@ -71,6 +72,7 @@ def render_nebula_release(version, stability, files, config): meta = metadata.copy() meta['version'] = str(version) meta['stability'] = stability # This can be one of ('stable', 'rc', 'nightly') + meta['private'] = config['nebula'].get('private', True) # OFP: default private until explicitly published for file in files: if file.content_hashes is None: @@ -83,17 +85,23 @@ def render_nebula_release(version, stability, files, config): if file.subgroup: group += '-' + file.subgroup + file_entry = { + 'dest': platforms[group] + '/' + subdirs.get(group, ''), + 'filesize': file.size, + 'checksum': ['sha256', file.hash], + 'filename': file.name + } + if config['nebula'].get('use_urls'): + # Option A: register externally-hosted (GitHub) URLs. Requires the account to be + # in Nebula's URLS_FOR allowlist. When off, the archive is uploaded to Nebula + # (Option B) and matched here by checksum instead. + file_entry['urls'] = [file.url] + file.mirrors + pkg = { 'name': group, 'notes': '', 'is_vp': False, - 'files': [{ - 'dest': platforms[group] + '/' + subdirs.get(group, ''), - 'filesize': file.size, - 'checksum': ['sha256', file.hash], - 'urls': [file.url] + file.mirrors, - 'filename': file.name - }], + 'files': [file_entry], 'environment': envs.get(group), 'filelist': [], 'status': 'required', @@ -220,6 +228,81 @@ def nebula_request(session, kind, path, **kwargs): return session.request(kind, uri, **request_args) +# Size of each chunk sent to Nebula's multiupload endpoint (Option B uploads). +UPLOAD_CHUNK_SIZE = 10 * 1024 * 1024 + + +def login_session(config): + """! Opens a requests Session and logs into Nebula. + + @returns (session, token). token is None if the login failed. + """ + session = requests.Session() + result = nebula_request(session, 'post', 'login', data={ + 'user': config['nebula']['user'], + 'password': config['nebula']['password'] + }) + if result.status_code != 200 or not result.json().get('result'): + return session, None + return session, result.json()['token'] + + +def upload_chunked(session, token, fileobj, checksum, filesize): + """! Uploads a file into Nebula's own storage via the multiupload API (Option B). + + No-op if a file with this checksum is already present. `fileobj` must be a seekable + binary stream; `checksum` is its sha256 (also used as the upload id). + + @returns True on success, False otherwise. + """ + headers = {'X-KN-TOKEN': token} + + # Skip if Nebula already has this exact file (re-runs, or shared across releases) + chk = nebula_request(session, 'post', 'upload/check', headers=headers, + data={'checksum': checksum}) + if chk.status_code == 200 and chk.json().get('result'): + print(" already on Nebula, skipping upload: {}".format(checksum)) + return True + + parts = max(1, (filesize + UPLOAD_CHUNK_SIZE - 1) // UPLOAD_CHUNK_SIZE) + + start = nebula_request(session, 'post', 'multiupload/start', headers=headers, + data={'id': checksum, 'size': str(filesize), 'parts': str(parts)}) + if start.status_code != 200 or not start.json().get('result'): + print(" ERROR: multiupload/start failed for {}".format(checksum)) + return False + finished = set(start.json().get('finished_parts', [])) + + fileobj.seek(0) + for idx in range(parts): + chunk = fileobj.read(UPLOAD_CHUNK_SIZE) + if idx in finished: + continue + + part = nebula_request(session, 'post', 'multiupload/part', headers=headers, + data={'id': checksum, 'part': str(idx)}, + files={'file': ('chunk', chunk)}) + if part.status_code != 200: + print(" ERROR: multiupload/part {} failed for {}".format(idx, checksum)) + return False + + verify = nebula_request(session, 'post', 'multiupload/verify_part', headers=headers, + data={'id': checksum, 'part': str(idx), + 'checksum': hashlib.sha256(chunk).hexdigest()}) + if verify.status_code != 200 or not verify.json().get('result'): + print(" ERROR: multiupload/verify_part {} failed for {}".format(idx, checksum)) + return False + + finish = nebula_request(session, 'post', 'multiupload/finish', headers=headers, + data={'id': checksum, 'checksum': checksum}) + if finish.status_code != 200 or not finish.json().get('result'): + print(" ERROR: multiupload/finish failed for {}: {}".format(checksum, finish.text[:200])) + return False + + print(" uploaded to Nebula: {}".format(checksum)) + return True + + def submit_release(meta, config): try: with requests.Session() as session: diff --git a/cmake/project.h.in b/cmake/project.h.in index c48e03ed14a..24f496b69dd 100644 --- a/cmake/project.h.in +++ b/cmake/project.h.in @@ -14,4 +14,8 @@ #define FS_VERSION_FULL "@FSO_FULL_VERSION_STRING@" #define FS_PRODUCT_VERSION "@FSO_PRODUCT_VERSION_STRING@" +// Fork identity: empty on stock builds, "OFP" on Origin FreeSpace Project builds. +// Kept separate from the numeric version so mod version checks and Nebula semver are unaffected. +#define FSO_BUILD_FORK_NAME "@FSO_BUILD_FORK_NAME@" + #endif // __PROJECT_H diff --git a/cmake/version.cmake b/cmake/version.cmake index fd7a4710242..9c9cf0e856f 100644 --- a/cmake/version.cmake +++ b/cmake/version.cmake @@ -16,6 +16,11 @@ set_if_not_defined(FSO_VERSION_REVISION 0) # A generic revision string, can be anything set_if_not_defined(FSO_VERSION_REVISION_STR "${FSO_VERSION_REVISION}") +# --- OFP fork identity (Origin FreeSpace Project) --- +# Brands builds as OFP in the version string and log header WITHOUT changing the numeric +# version, so mod required_fso_version checks and Nebula semver stay intact. Empty = stock build. +set_if_not_defined(FSO_BUILD_FORK_NAME "OFP") + if("${FSO_VERSION_REVISION}" STREQUAL "0" AND "${FSO_VERSION_REVISION_STR}" STREQUAL "${FSO_VERSION_REVISION}") set(FSO_VERSION_HAS_REVISION 0) else() diff --git a/code/coordinate_points/coordinate_point.cpp b/code/coordinate_points/coordinate_point.cpp new file mode 100644 index 00000000000..bc32bb62f48 --- /dev/null +++ b/code/coordinate_points/coordinate_point.cpp @@ -0,0 +1,172 @@ +#include "coordinate_points/coordinate_point.h" + +#include "coordinate_points/coordinate_shapes.h" +#include "object/object.h" +#include "math/vecmat.h" +#include "parse/parselo.h" + +SCP_list Coordinate_points; +SCP_vector Parse_coordinate_points; + +static constexpr float COORDINATE_POINT_DEFAULT_RADIUS = 1.0f; + +mission_coordinate_point::mission_coordinate_point() +{ + gr_init_alphacolor(&display_color, 255, 255, 255, 255); +} + +parsed_coordinate_point::parsed_coordinate_point() +{ + position = vmd_zero_vector; + gr_init_alphacolor(&display_color, 255, 255, 255, 255); +} + +void coordinate_points_level_close() +{ + // Snapshot the objnums and clear the list first so the per-object obj_delete callback + // (which calls coordinate_point_delete) doesn't try to erase from the list we're walking. + SCP_vector objnums; + objnums.reserve(Coordinate_points.size()); + for (const auto& cp : Coordinate_points) { + if (cp.objnum >= 0) { + objnums.push_back(cp.objnum); + } + } + Coordinate_points.clear(); + + for (int objnum : objnums) { + if (Objects[objnum].type != OBJ_NONE) { + obj_delete(objnum); + } + } +} + +static bool coordinate_point_name_in_use(const char* name) +{ + for (const auto& cp : Coordinate_points) { + if (!stricmp(cp.name.c_str(), name)) { + return true; + } + } + return false; +} + +static SCP_string make_unique_default_name() +{ + for (int i = 1;; ++i) { + SCP_string candidate; + sprintf(candidate, "Coordinate Point %d", i); + if (!coordinate_point_name_in_use(candidate.c_str())) { + return candidate; + } + } +} + +int coordinate_point_create(const vec3d* pos, const char* name) +{ + Assertion(pos != nullptr, "coordinate_point_create requires a non-null position"); + + flagset flags; + flags.set(Object::Object_Flags::Renders); + // Coordinate points have no meaningful orientation; mark that explicitly so any future + // physics/rotation code shortcuts cleanly. Position is left mutable so SEXPs and Lua + // can relocate the point at runtime. + flags.set(Object::Object_Flags::Dont_change_orientation); + int objnum = obj_create(OBJ_COORDINATE_POINT, -1, -1, nullptr, pos, COORDINATE_POINT_DEFAULT_RADIUS, flags); + if (objnum < 0) { + return -1; + } + + Coordinate_points.emplace_back(); + auto& cp = Coordinate_points.back(); + if (name != nullptr && *name != '\0' && !coordinate_point_name_in_use(name)) { + cp.name = name; + } else { + cp.name = make_unique_default_name(); + } + cp.objnum = objnum; + + return objnum; +} + +void coordinate_point_delete(int objnum) +{ + for (auto it = Coordinate_points.begin(); it != Coordinate_points.end(); ++it) { + if (it->objnum == objnum) { + Coordinate_points.erase(it); + return; + } + } +} + +mission_coordinate_point* find_coordinate_point_by_name(const char* name) +{ + if (name == nullptr) { + return nullptr; + } + for (auto& cp : Coordinate_points) { + if (!stricmp(cp.name.c_str(), name)) { + return &cp; + } + } + return nullptr; +} + +mission_coordinate_point* find_coordinate_point_by_objnum(int objnum) +{ + if (objnum < 0) { + return nullptr; + } + for (auto& cp : Coordinate_points) { + if (cp.objnum == objnum) { + return &cp; + } + } + return nullptr; +} + +void post_process_mission_coordinate_points() +{ + for (const auto& parsed : Parse_coordinate_points) { + int objnum = coordinate_point_create(&parsed.position, + parsed.name.empty() ? nullptr : parsed.name.c_str()); + if (objnum < 0) { + continue; + } + + auto* cp = find_coordinate_point_by_objnum(objnum); + if (cp == nullptr) { + continue; + } + + cp->group = parsed.group; + cp->display_color = parsed.display_color; + cp->shape_kind = parsed.shape_kind; + cp->shape_sides = parsed.shape_sides; + cp->shape_points = parsed.shape_points; + cp->shape_inner_radius = parsed.shape_inner_radius; + cp->shape_angle_deg = parsed.shape_angle_deg; + cp->size_scale = parsed.size_scale; + cp->escort_priority = parsed.escort_priority; + cp->multi_team = parsed.multi_team; + cp->flags = parsed.flags; + cp->fred_layer = parsed.fred_layer; + + // Resolve a tabled-shape name to an index. If the name doesn't match anything in the + // registry (default table + TBMs), fall back to NGon(3) with a warning so the mission + // still loads cleanly. + if (cp->shape_kind == CoordinatePointShapeKind::Tabled) { + cp->shape_table_index = find_coordinate_shape_index_by_name(parsed.shape_table_name.c_str()); + if (cp->shape_table_index < 0) { + Warning(LOCATION, + "Coordinate point '%s' references unknown shape '%s'; falling back to NGon(3).", + cp->name.c_str(), + parsed.shape_table_name.c_str()); + cp->shape_kind = CoordinatePointShapeKind::NGon; + cp->shape_sides = 3; + } + } + } + + Parse_coordinate_points.clear(); +} diff --git a/code/coordinate_points/coordinate_point.h b/code/coordinate_points/coordinate_point.h new file mode 100644 index 00000000000..b2e4be9b9e5 --- /dev/null +++ b/code/coordinate_points/coordinate_point.h @@ -0,0 +1,92 @@ +#pragma once + +#include "coordinate_points/coordinate_point_flags.h" +#include "globalincs/pstypes.h" +#include "graphics/2d.h" + +struct vec3d; + +// What kind of 2D shape this coordinate point draws as. NGon and Star are procedural primitives +// driven by parameter fields below. Tabled refers to a custom vertex+triangle definition loaded +// from coordinate_points.tbl / *-cps.tbm. +enum class CoordinatePointShapeKind : int { + NGon = 0, + Star, + Tabled, +}; + +constexpr float COORDINATE_POINT_SIZE_MIN = 0.25f; +constexpr float COORDINATE_POINT_SIZE_MAX = 8.0f; + +constexpr int NGON_SIDES_MIN = 3; +constexpr int NGON_SIDES_MAX = 32; +constexpr int STAR_POINTS_MIN = 3; +constexpr int STAR_POINTS_MAX = 32; +constexpr float STAR_INNER_MIN = 0.05f; +constexpr float STAR_INNER_MAX = 0.95f; +constexpr float STAR_INNER_DEFAULT = 0.382f; // classic 5-point arm angle + +struct mission_coordinate_point +{ + SCP_string name; + SCP_string group; + color display_color; + + CoordinatePointShapeKind shape_kind = CoordinatePointShapeKind::NGon; + int shape_sides = 4; // NGon: clamp [NGON_SIDES_MIN, NGON_SIDES_MAX] + int shape_points = 5; // Star: clamp [STAR_POINTS_MIN, STAR_POINTS_MAX] + float shape_inner_radius = STAR_INNER_DEFAULT; // Star: clamp [STAR_INNER_MIN, STAR_INNER_MAX] + int shape_table_index = -1; // Tabled: index into Coordinate_shapes; -1 = unset + float shape_angle_deg = 0.0f; // all: extra rotation around local Z + + float size_scale = 1.0f; + int escort_priority = 0; // 0 = not on escort list; >0 = on the list (and sort key) + int multi_team = -1; // -1 = visible to all; otherwise TVT team index (0..MAX_TVT_TEAMS-1) + flagset flags; + SCP_string fred_layer = "Default"; // FRED view layer assignment + int objnum = -1; + + mission_coordinate_point(); +}; + +extern SCP_list Coordinate_points; + +struct parsed_coordinate_point +{ + SCP_string name; + SCP_string group; + vec3d position; + color display_color; + + CoordinatePointShapeKind shape_kind = CoordinatePointShapeKind::NGon; + int shape_sides = 4; + int shape_points = 5; + float shape_inner_radius = STAR_INNER_DEFAULT; + SCP_string shape_table_name; // resolved to shape_table_index at post-process time + float shape_angle_deg = 0.0f; + + float size_scale = 1.0f; + int escort_priority = 0; + int multi_team = -1; + flagset flags; + SCP_string fred_layer = "Default"; + + parsed_coordinate_point(); +}; + +extern SCP_vector Parse_coordinate_points; + +void coordinate_points_level_close(); + +void post_process_mission_coordinate_points(); + +// Creates a coordinate point at the given position, allocating an Objects[] slot. +// If name is null or empty, an auto-name "Coordinate Point N" is generated (unique in mission). +// Returns the objnum on success, or -1 on failure. +int coordinate_point_create(const vec3d* pos, const char* name = nullptr); + +// Removes the coordinate point whose Objects[] slot is objnum. +void coordinate_point_delete(int objnum); + +mission_coordinate_point* find_coordinate_point_by_name(const char* name); +mission_coordinate_point* find_coordinate_point_by_objnum(int objnum); diff --git a/code/coordinate_points/coordinate_point_flags.h b/code/coordinate_points/coordinate_point_flags.h new file mode 100644 index 00000000000..a49cdf22e23 --- /dev/null +++ b/code/coordinate_points/coordinate_point_flags.h @@ -0,0 +1,12 @@ +#pragma once + +#include "globalincs/flagset.h" + +namespace CoordinatePoint { + +FLAG_LIST(Flags){ + Visible_in_mission, // Render the shape in-game (not just in the editor) and allow target-in-front pickup. + + NUM_VALUES}; + +} // namespace CoordinatePoint diff --git a/code/coordinate_points/coordinate_point_render.cpp b/code/coordinate_points/coordinate_point_render.cpp new file mode 100644 index 00000000000..90e7705eb24 --- /dev/null +++ b/code/coordinate_points/coordinate_point_render.cpp @@ -0,0 +1,250 @@ +#include "coordinate_points/coordinate_point_render.h" + +#include "coordinate_points/coordinate_point.h" +#include "coordinate_points/coordinate_shapes.h" +#include "graphics/material.h" +#include "math/floating.h" +#include "math/vecmat.h" +#include "network/multi.h" +#include "object/object.h" +#include "render/3d.h" + +#include + +namespace { + +struct vec2f { + float x, y; +}; + +// Cap on locally-built 2D vertex/triangle buffers. NGon caps out at NGON_SIDES_MAX (32), Star +// caps at 2 * STAR_POINTS_MAX (64), and tabled shapes are bounded by author hand. 128 / 384 +// leaves plenty of headroom. +constexpr int MAX_LOCAL_VERTS = 128; +constexpr int MAX_LOCAL_TRIS = MAX_LOCAL_VERTS * 3; + +constexpr float PI_F = 3.14159265358979323846f; + +// Generate a regular N-gon centered on (0,0), unit radius, first vertex at the top +// (i.e. starting angle = π/2). CCW winding. Triangulated as a fan from vertex 0. +void build_ngon(int sides, vec2f* out_verts, int& out_nv, int* out_tris, int& out_ntri_idx) +{ + if (sides < NGON_SIDES_MIN) sides = NGON_SIDES_MIN; + if (sides > NGON_SIDES_MAX) sides = NGON_SIDES_MAX; + + const float start = PI_F * 0.5f; // top + const float step = 2.0f * PI_F / static_cast(sides); + for (int i = 0; i < sides; ++i) { + // CCW means angles increase as i increases (standard math convention with +Y up). + const float a = start + step * static_cast(i); + out_verts[i].x = std::cos(a); + out_verts[i].y = std::sin(a); + } + out_nv = sides; + + // Fan from vertex 0: triangles (0, i, i+1) for i in [1, sides-2]. + int n = 0; + for (int i = 1; i < sides - 1; ++i) { + out_tris[n++] = 0; + out_tris[n++] = i; + out_tris[n++] = i + 1; + } + out_ntri_idx = n; +} + +// Generate an N-pointed star, unit outer radius, first outer point at the top. Alternates outer +// (1.0) and inner (inner_r) radius. Triangulated as N arm triangles (outer, inner_prev, inner_next) +// plus an inner-polygon fan from inner vertex 1. +void build_star(int points, float inner_r, vec2f* out_verts, int& out_nv, int* out_tris, int& out_ntri_idx) +{ + if (points < STAR_POINTS_MIN) points = STAR_POINTS_MIN; + if (points > STAR_POINTS_MAX) points = STAR_POINTS_MAX; + if (inner_r < STAR_INNER_MIN) inner_r = STAR_INNER_MIN; + if (inner_r > STAR_INNER_MAX) inner_r = STAR_INNER_MAX; + + const int nv = points * 2; + const float start = PI_F * 0.5f; // first outer vert at top + const float step = PI_F / static_cast(points); // half-step between outer & inner + for (int i = 0; i < nv; ++i) { + const float a = start + step * static_cast(i); + const float r = (i & 1) ? inner_r : 1.0f; // even=outer, odd=inner + out_verts[i].x = std::cos(a) * r; + out_verts[i].y = std::sin(a) * r; + } + out_nv = nv; + + int n = 0; + // Arms: each outer vertex paired with its two neighbouring inner vertices. + // Outer at index 2k (k = 0..points-1); neighbours are inner at indices 2k-1 (mod nv) and 2k+1. + for (int k = 0; k < points; ++k) { + const int outer = 2 * k; + const int prev_inner = (outer - 1 + nv) % nv; + const int next_inner = (outer + 1) % nv; + out_tris[n++] = prev_inner; + out_tris[n++] = outer; + out_tris[n++] = next_inner; + } + // Inner polygon: vertices at odd indices 1, 3, 5, ..., nv-1. Fan from vertex 1. + for (int k = 1; k + 1 < points; ++k) { + out_tris[n++] = 1; + out_tris[n++] = 1 + 2 * k; + out_tris[n++] = 1 + 2 * (k + 1); + } + out_ntri_idx = n; +} + +// Copy a tabled shape into the local buffers. Caller must have validated def has room. +bool copy_tabled(const coordinate_shape_def& def, vec2f* out_verts, int& out_nv, int* out_tris, int& out_ntri_idx) +{ + if (static_cast(def.verts.size()) > MAX_LOCAL_VERTS || + static_cast(def.tri_indices.size()) > MAX_LOCAL_TRIS) { + return false; + } + out_nv = static_cast(def.verts.size()); + for (int i = 0; i < out_nv; ++i) { + out_verts[i].x = def.verts[i].x; + out_verts[i].y = def.verts[i].y; + } + out_ntri_idx = static_cast(def.tri_indices.size()); + for (int i = 0; i < out_ntri_idx; ++i) { + out_tris[i] = def.tri_indices[i]; + } + return true; +} + +// Build the shape's 2D verts + triangle indices for the given coord point. Returns false if +// the shape can't be resolved (out-of-range tabled index, oversized table entry). +bool build_shape_local(const mission_coordinate_point& cp, vec2f* verts, int& nv, int* tris, int& nti) +{ + switch (cp.shape_kind) { + case CoordinatePointShapeKind::NGon: + build_ngon(cp.shape_sides, verts, nv, tris, nti); + return true; + case CoordinatePointShapeKind::Star: + build_star(cp.shape_points, cp.shape_inner_radius, verts, nv, tris, nti); + return true; + case CoordinatePointShapeKind::Tabled: { + const int idx = cp.shape_table_index; + if (idx < 0 || idx >= static_cast(Coordinate_shapes.size())) { + return false; + } + return copy_tabled(Coordinate_shapes[idx], verts, nv, tris, nti); + } + } + return false; +} + +} // anonymous namespace + +float get_coordinate_point_world_radius(const mission_coordinate_point& cp, const vec3d& camera_eye) +{ + if (cp.objnum < 0) { + return COORDINATE_POINT_LOLLIPOP_SIZE * cp.size_scale; + } + const vec3d& pos = Objects[cp.objnum].pos; + float dist = vm_vec_dist(&camera_eye, &pos); + float size = fl_sqrt(dist / COORDINATE_POINT_DIST_DIVISOR) * cp.size_scale; + const float floor_size = COORDINATE_POINT_LOLLIPOP_SIZE * cp.size_scale; + if (size < floor_size) { + size = floor_size; + } + return size; +} + +void draw_coordinate_point_shape(const mission_coordinate_point& cp, + const vec3d* camera_eye, + const matrix* camera_orient) +{ + if (cp.objnum < 0) { + return; + } + + vec2f local_verts[MAX_LOCAL_VERTS]; + int local_tris[MAX_LOCAL_TRIS]; + int nv = 0, nti = 0; + if (!build_shape_local(cp, local_verts, nv, local_tris, nti)) { + return; + } + + // Apply the per-instance rotation (degrees → radians) in 2D before billboarding. + if (cp.shape_angle_deg != 0.0f) { + const float a = cp.shape_angle_deg * (PI_F / 180.0f); + const float ca = std::cos(a); + const float sa = std::sin(a); + for (int i = 0; i < nv; ++i) { + const float x = local_verts[i].x; + const float y = local_verts[i].y; + local_verts[i].x = ca * x - sa * y; + local_verts[i].y = sa * x + ca * y; + } + } + + const vec3d& pos = Objects[cp.objnum].pos; + const float radius = get_coordinate_point_world_radius(cp, *camera_eye); + + const vec3d& rvec = camera_orient->vec.rvec; + const vec3d& uvec = camera_orient->vec.uvec; + + // Build camera-facing world-space vertices, then expand the triangle index list into a flat + // vertex array for PRIM_TYPE_TRIS. + vec3d world_verts[MAX_LOCAL_VERTS]; + for (int i = 0; i < nv; ++i) { + world_verts[i] = pos; + vm_vec_scale_add2(&world_verts[i], &rvec, local_verts[i].x * radius); + vm_vec_scale_add2(&world_verts[i], &uvec, local_verts[i].y * radius); + } + + vertex tri_verts[MAX_LOCAL_TRIS]; + for (int i = 0; i < nti; ++i) { + g3_transfer_vertex(&tri_verts[i], &world_verts[local_tris[i]]); + } + + material mat; + mat.set_color(cp.display_color); + // Depth-test but DON'T write to depth: the lightshaft post-pass samples the depth texture + // to find occluders, and coordinate points are conceptually HUD overlays, not physical + // objects, so they shouldn't carve shadow shafts into the lightshaft pattern. + mat.set_depth_mode(ZBUFFER_TYPE_READ); + mat.set_blend_mode(ALPHA_BLEND_ALPHA_BLEND_ALPHA); + + g3_render_primitives(&mat, tri_verts, nti, PRIM_TYPE_TRIS, false); +} + +void coordinate_points_render_all_in_mission() +{ + bool any_drawn = false; + const bool in_multi = (Net_player != nullptr); + const int local_multi_team = in_multi ? Net_player->p_info.team : -1; + + for (const auto& cp : Coordinate_points) { + if (cp.objnum < 0) { + continue; + } + if (!cp.flags[CoordinatePoint::Flags::Visible_in_mission]) { + continue; + } + // Multiplayer team filter: if this point is assigned to a specific team and the local + // player is on a different team, skip rendering. Singleplayer and "no team" (team < 0) + // points always render. + if (in_multi && cp.multi_team >= 0 && cp.multi_team != local_multi_team) { + continue; + } + draw_coordinate_point_shape(cp, &Eye_position, &Eye_matrix); + any_drawn = true; + } + // g3_render_primitives leaks the material's color into the immediate-mode draw state. + // Reset it so downstream HUD draws see a known starting color. + if (any_drawn) { + gr_set_color(255, 255, 255); + } +} + +void coordinate_points_render_all_in_editor(const vec3d* camera_eye, const matrix* camera_orient) +{ + for (const auto& cp : Coordinate_points) { + if (cp.objnum < 0) { + continue; + } + draw_coordinate_point_shape(cp, camera_eye, camera_orient); + } +} diff --git a/code/coordinate_points/coordinate_point_render.h b/code/coordinate_points/coordinate_point_render.h new file mode 100644 index 00000000000..400500aa19f --- /dev/null +++ b/code/coordinate_points/coordinate_point_render.h @@ -0,0 +1,31 @@ +#pragma once + +#include "coordinate_points/coordinate_point.h" + +struct vec3d; +struct matrix; + +// Distance-scaled size constants: matches the waypoint sphere sizing in qtFRED so coord points +// read at similar scale to existing markers. +constexpr float COORDINATE_POINT_LOLLIPOP_SIZE = 2.0f; +constexpr float COORDINATE_POINT_DIST_DIVISOR = 20.0f; + +// Draw a single coordinate point as a camera-facing line-loop polygon at its world position. +// Uses the supplied camera orientation for right/up vectors. The camera eye is needed for +// distance-based sizing. Color, shape, and size_scale come from the coord point itself. +void draw_coordinate_point_shape(const mission_coordinate_point& cp, + const vec3d* camera_eye, + const matrix* camera_orient); + +// Returns the world-space radius the shape will be drawn at given the camera eye position. +// Exposed for HUD target-bracket sizing so the bracket fits the rendered shape. +float get_coordinate_point_world_radius(const mission_coordinate_point& cp, const vec3d& camera_eye); + +// In-game render pass. Iterates Coordinate_points, filters by the Visible_in_mission flag, and +// draws each visible coord point using the global Eye_position / Eye_matrix. +void coordinate_points_render_all_in_mission(); + +// Editor render pass. Iterates Coordinate_points and draws each one regardless of the +// Visible_in_mission flag, using the supplied camera. Caller is responsible for any +// view-level "show coordinate points" toggle. +void coordinate_points_render_all_in_editor(const vec3d* camera_eye, const matrix* camera_orient); diff --git a/code/coordinate_points/coordinate_shapes.cpp b/code/coordinate_points/coordinate_shapes.cpp new file mode 100644 index 00000000000..d2f7083d3a5 --- /dev/null +++ b/code/coordinate_points/coordinate_shapes.cpp @@ -0,0 +1,145 @@ +#include "coordinate_points/coordinate_shapes.h" + +#include "cfile/cfile.h" +#include "def_files/def_files.h" +#include "parse/parselo.h" + +SCP_vector Coordinate_shapes; + +namespace { + +constexpr const char* DEFAULT_TABLE_NAME = "coordinate_points.tbl"; +constexpr const char* MODULAR_TABLE_GLOB = "*-cps.tbm"; + +// Reserved built-in shape kind keywords. Tabled entries cannot use these names. +bool is_reserved_shape_name(const char* name) +{ + return !stricmp(name, "NGon") || !stricmp(name, "Star"); +} + +bool shape_name_exists(const char* name) +{ + for (const auto& s : Coordinate_shapes) { + if (!stricmp(s.name.c_str(), name)) { + return true; + } + } + return false; +} + +void parse_coordinate_points_table(const char* filename) +{ + try { + if (filename != nullptr) { + read_file_text(filename, CF_TYPE_TABLES); + } else { + read_file_text_from_default(defaults_get_file(DEFAULT_TABLE_NAME)); + } + reset_parse(); + + required_string("#Coordinate Shapes"); + while (optional_string("$Name:")) { + // optional_string consumed the keyword; read the name directly here rather than + // rewinding for a helper. + SCP_string name; + stuff_string(name, F_NAME); + + bool skip = false; + if (is_reserved_shape_name(name.c_str())) { + Warning(LOCATION, + "Coordinate shape name '%s' is reserved (built-in kind); entry skipped.", + name.c_str()); + skip = true; + } else if (shape_name_exists(name.c_str())) { + Warning(LOCATION, + "Coordinate shape '%s' is already defined; duplicate entry skipped.", + name.c_str()); + skip = true; + } + + SCP_vector raw_verts; + SCP_vector raw_tris; + + required_string("$Vertices:"); + stuff_float_list(raw_verts); + + required_string("$Triangles:"); + stuff_int_list(raw_tris); + + if (skip) { + continue; + } + + if (raw_verts.empty() || (raw_verts.size() % 2) != 0) { + Warning(LOCATION, + "Coordinate shape '%s' has %d vertex floats; must be a non-zero even count (x y pairs). Entry skipped.", + name.c_str(), static_cast(raw_verts.size())); + continue; + } + if (raw_tris.empty() || (raw_tris.size() % 3) != 0) { + Warning(LOCATION, + "Coordinate shape '%s' has %d triangle indices; must be a non-zero multiple of 3. Entry skipped.", + name.c_str(), static_cast(raw_tris.size())); + continue; + } + + const int vert_count = static_cast(raw_verts.size() / 2); + bool indices_valid = true; + for (int idx : raw_tris) { + if (idx < 0 || idx >= vert_count) { + Warning(LOCATION, + "Coordinate shape '%s' triangle index %d is out of range [0, %d). Entry skipped.", + name.c_str(), idx, vert_count); + indices_valid = false; + break; + } + } + if (!indices_valid) { + continue; + } + + coordinate_shape_def def; + def.name = std::move(name); + def.verts.reserve(vert_count); + for (size_t i = 0; i < raw_verts.size(); i += 2) { + def.verts.push_back({raw_verts[i], raw_verts[i + 1]}); + } + def.tri_indices = std::move(raw_tris); + + Coordinate_shapes.push_back(std::move(def)); + } + required_string("#End"); + } catch (const parse::ParseException& e) { + mprintf(("TABLES: Unable to parse '%s'! Error message = %s.\n", + filename ? filename : DEFAULT_TABLE_NAME, e.what())); + } +} + +} // anonymous namespace + +void coordinate_shapes_init() +{ + Coordinate_shapes.clear(); + + // Default table first so its names take precedence over any colliding mod entries. + if (cf_exists_full(DEFAULT_TABLE_NAME, CF_TYPE_TABLES)) { + parse_coordinate_points_table(DEFAULT_TABLE_NAME); + } else { + parse_coordinate_points_table(nullptr); // compiled-in default + } + + parse_modular_table(MODULAR_TABLE_GLOB, parse_coordinate_points_table); +} + +int find_coordinate_shape_index_by_name(const char* name) +{ + if (name == nullptr) { + return -1; + } + for (size_t i = 0; i < Coordinate_shapes.size(); ++i) { + if (!stricmp(Coordinate_shapes[i].name.c_str(), name)) { + return static_cast(i); + } + } + return -1; +} diff --git a/code/coordinate_points/coordinate_shapes.h b/code/coordinate_points/coordinate_shapes.h new file mode 100644 index 00000000000..0d56866ad35 --- /dev/null +++ b/code/coordinate_points/coordinate_shapes.h @@ -0,0 +1,25 @@ +#pragma once + +#include "globalincs/pstypes.h" + +// Tabled custom shapes for coordinate points. Loaded from coordinate_points.tbl plus any +// *-cps.tbm modular tables. TBMs are additive only -- duplicate names and the reserved names +// "NGon" / "Star" are rejected with a warning. First definition wins, default table loads first. + +struct coordinate_shape_vec2f { + float x, y; +}; + +struct coordinate_shape_def { + SCP_string name; + SCP_vector verts; + SCP_vector tri_indices; // size always a multiple of 3; indices in [0, verts.size()) +}; + +extern SCP_vector Coordinate_shapes; + +// Loads the default table and any modular tables. Safe to call once at game init. +void coordinate_shapes_init(); + +// Returns -1 if no shape matches the given name (case-insensitive). nullptr is treated as miss. +int find_coordinate_shape_index_by_name(const char* name); diff --git a/code/def_files/data/tables/coordinate_points.tbl b/code/def_files/data/tables/coordinate_points.tbl new file mode 100644 index 00000000000..1e4231d3de9 --- /dev/null +++ b/code/def_files/data/tables/coordinate_points.tbl @@ -0,0 +1,23 @@ +#Coordinate Shapes + +; Default tabled shapes for coordinate points. Mods may add new shapes via *-cps.tbm files. +; TBMs are additive only: duplicate names and the reserved names "NGon" and "Star" are rejected +; with a warning. +; +; Each entry needs $Vertices: (a flat list of x y floats, unit radius, point-up convention) and +; $Triangles: (a flat list of CCW triangle indices into the vertex list, three per triangle). + +$Name: Cross +$Vertices: ( + -0.33 1.00 0.33 1.00 0.33 0.33 1.00 0.33 + 1.00 -0.33 0.33 -0.33 0.33 -1.00 -0.33 -1.00 + -0.33 -0.33 -1.00 -0.33 -1.00 0.33 -0.33 0.33 +) +$Triangles: ( + ; Vertical bar + 0 7 6 0 6 1 + ; Horizontal bar + 10 9 4 10 4 3 +) + +#End diff --git a/code/globalincs/version.cpp b/code/globalincs/version.cpp index 3623109759c..8d89f504fea 100644 --- a/code/globalincs/version.cpp +++ b/code/globalincs/version.cpp @@ -193,7 +193,11 @@ SCP_string get_version_string() { SCP_string str; - sprintf(str, "FreeSpace 2 Open v%s", FS_VERSION_FULL); + if (FSO_BUILD_FORK_NAME[0] != '\0') { + sprintf(str, "FreeSpace 2 Open (%s) v%s", FSO_BUILD_FORK_NAME, FS_VERSION_FULL); + } else { + sprintf(str, "FreeSpace 2 Open v%s", FS_VERSION_FULL); + } #ifndef NDEBUG str += " Debug"; diff --git a/code/hud/hudbrackets.cpp b/code/hud/hudbrackets.cpp index 0337ad4845b..5e54c1a2f0b 100644 --- a/code/hud/hudbrackets.cpp +++ b/code/hud/hudbrackets.cpp @@ -15,6 +15,8 @@ #include "hud/hudbrackets.h" #include "hud/hudtarget.h" #include "iff_defs/iff_defs.h" +#include "coordinate_points/coordinate_point.h" +#include "coordinate_points/coordinate_point_render.h" #include "jumpnode/jumpnode.h" #include "mission/missionparse.h" #include "object/object.h" @@ -455,6 +457,39 @@ void HudGaugeBrackets::renderObjectBrackets(object *targetp, color *clr, int w_c } break; + case OBJ_COORDINATE_POINT: + { + // Coordinate points have no model. Size the bracket so it matches the rendered + // shape: use the same world-space radius the renderer uses, project a point offset + // along the camera right vector, and measure the screen-space delta to derive a + // half-extent. This automatically scales with both distance and the coord point's + // size_scale field. + vertex v_center; + g3_rotate_vertex(&v_center, &targetp->pos); + g3_project_vertex(&v_center); + + int half_extent = 0; + auto* cp = find_coordinate_point_by_objnum(OBJ_INDEX(targetp)); + if (cp != nullptr) { + const float radius = get_coordinate_point_world_radius(*cp, Eye_position); + vec3d offset_pos = targetp->pos; + vm_vec_scale_add2(&offset_pos, &Eye_matrix.vec.rvec, radius); + + vertex v_offset; + g3_rotate_vertex(&v_offset, &offset_pos); + g3_project_vertex(&v_offset); + + half_extent = std::abs(static_cast(v_offset.screen.xyw.x) - + static_cast(v_center.screen.xyw.x)); + } + + x1 = static_cast(v_center.screen.xyw.x) - half_extent; + x2 = static_cast(v_center.screen.xyw.x) + half_extent; + y1 = static_cast(v_center.screen.xyw.y) - half_extent; + y2 = static_cast(v_center.screen.xyw.y) + half_extent; + } + break; + default: Int3(); // should never happen return; @@ -470,7 +505,15 @@ void HudGaugeBrackets::renderObjectBrackets(object *targetp, color *clr, int w_c Hud_target_h = gr_screen.clip_height; } - if(clr->red && clr->green && clr->blue ) { + if (targetp->type == OBJ_COORDINATE_POINT) { + // Coordinate points always use their own display color, even if a component is zero. + auto* cp = find_coordinate_point_by_objnum(OBJ_INDEX(targetp)); + if (cp != nullptr) { + gr_set_color_fast(&cp->display_color); + } else { + gr_set_color_fast(clr); + } + } else if(clr->red && clr->green && clr->blue ) { gr_set_color_fast(clr); } else { // if no specific color defined, use the IFF color. @@ -671,10 +714,20 @@ void HudGaugeBrackets::renderBoundingBrackets(int x1, int y1, int x2, int y2, in tinfo_name = XSTR("debris", 348); } break; - case OBJ_JUMP_NODE: + case OBJ_JUMP_NODE: { auto jnp = jumpnode_get_by_objnum(target_objnum); tinfo_name = jnp ? jnp->GetDisplayName() : ""; break; + } + case OBJ_COORDINATE_POINT: { + auto* cp = find_coordinate_point_by_objnum(target_objnum); + if (cp != nullptr) { + tinfo_name = cp->name.c_str(); + if (!cp->group.empty()) + tinfo_class = cp->group.c_str(); + } + break; + } } if(tinfo_name && (flags & TARGET_DISPLAY_NAME)) { diff --git a/code/hud/hudescort.cpp b/code/hud/hudescort.cpp index 579c254f59d..76c0c421c20 100644 --- a/code/hud/hudescort.cpp +++ b/code/hud/hudescort.cpp @@ -11,6 +11,7 @@ +#include "coordinate_points/coordinate_point.h" #include "gamesnd/gamesnd.h" #include "globalincs/alphacolors.h" #include "globalincs/linklist.h" @@ -331,6 +332,32 @@ void HudGaugeEscort::renderIcon(int x, int y, int index, float scale, bool confi object* objp = nullptr; ship* sp = nullptr; + // Coordinate-point entries render very differently: name + category, no D, no hull bar. + if (!config && Objects[objnum].type == OBJ_COORDINATE_POINT) { + auto* cp = find_coordinate_point_by_objnum(objnum); + if (cp == nullptr) { + return; + } + + gr_set_color_fast(&cp->display_color); + + char buf[NAME_LENGTH]; + snprintf(buf, sizeof(buf), "%s", cp->name.c_str()); + + const int w = font::force_fit_string(buf, NAME_LENGTH - 1, fl2i(ship_name_max_width * scale), scale); + if (right_align_names) { + renderString(x + fl2i(ship_name_offsets[0] * scale) + fl2i(ship_name_max_width * scale) - w, + y + fl2i(ship_name_offsets[1] * scale), EG_ESCORT1 + index, buf, scale, config); + } else { + renderString(x + fl2i(ship_name_offsets[0] * scale), y + fl2i(ship_name_offsets[1] * scale), + EG_ESCORT1 + index, buf, scale, config); + } + + // No 'D', no hull/shield integrity. Leave the bar region empty. + setGaugeColor(HUD_C_NONE, config); + return; + } + if (!config) { objp = &Objects[objnum]; sp = &Ships[objp->instance]; @@ -588,17 +615,28 @@ static bool escort_compare(const escort_info &escort1, const escort_info &escort if (escort1.priority != escort2.priority) { return (escort1.priority > escort2.priority); - } else if ( (escort1.objnum >= 0) && (Objects[escort1.objnum].instance >= 0) && - (escort2.objnum >= 0) && (Objects[escort2.objnum].instance >= 0) ) - { - const char *name1 = Ships[Objects[escort1.objnum].instance].ship_name; - const char *name2 = Ships[Objects[escort2.objnum].instance].ship_name; - - return (stricmp(name1, name2) > 0); } - } - return false; + // Equal priority: alphabetical by display name. Resolve names per type so + // coordinate points (which have no Ships[] entry) don't blow up the sort. + auto get_sort_name = [](const escort_info& es) -> const char* { + if (es.objnum < 0) + return ""; + const object& obj = Objects[es.objnum]; + if (obj.type == OBJ_SHIP && obj.instance >= 0) { + return Ships[obj.instance].ship_name; + } + if (obj.type == OBJ_COORDINATE_POINT) { + auto* cp = find_coordinate_point_by_objnum(es.objnum); + return (cp != nullptr) ? cp->name.c_str() : ""; + } + return ""; + }; + + const char* name1 = get_sort_name(escort1); + const char* name2 = get_sort_name(escort2); + return (stricmp(name1, name2) > 0); + } } // create complete priority sorted escort list for all active ships @@ -678,6 +716,22 @@ void hud_create_complete_escort_list() Escort_ships.push_back(einfo); } + + // Auto-add coordinate points whose escort priority is non-zero. + for (const auto& cp : Coordinate_points) { + if (cp.objnum < 0) + continue; + if (cp.escort_priority <= 0) + continue; + if (Objects[cp.objnum].flags[Object::Object_Flags::Should_be_dead]) + continue; + + escort_info einfo; + einfo.objnum = cp.objnum; + einfo.obj_signature = Objects[cp.objnum].signature; + einfo.priority = cp.escort_priority; + Escort_ships.push_back(einfo); + } } } @@ -780,6 +834,12 @@ void hud_escort_cull_list() remove = true; } } + } else if (Objects[objnum].type == OBJ_COORDINATE_POINT) { + // Drop if deleted or had escort priority cleared. + auto* cp = find_coordinate_point_by_objnum(objnum); + if (cp == nullptr || cp->escort_priority <= 0) { + remove = true; + } } if (remove) { @@ -817,7 +877,11 @@ void hud_add_ship_to_escort(int objnum, int supress_feedback) return; } - if (Objects[objnum].type != OBJ_SHIP) { + const auto& obj = Objects[objnum]; + const bool is_ship = (obj.type == OBJ_SHIP); + const bool is_coord_point = (obj.type == OBJ_COORDINATE_POINT); + + if (!is_ship && !is_coord_point) { if ( !supress_feedback ) { snd_play( gamesnd_get_game_sound(GameSounds::TARGET_FAIL)); } @@ -825,29 +889,42 @@ void hud_add_ship_to_escort(int objnum, int supress_feedback) return; } - // check if ship is already on complete escort list + // Coordinate points need a positive escort_priority to be added. + const mission_coordinate_point* cp_ptr = nullptr; + if (is_coord_point) { + cp_ptr = find_coordinate_point_by_objnum(objnum); + if (cp_ptr == nullptr || cp_ptr->escort_priority <= 0) { + if (!supress_feedback) { + snd_play(gamesnd_get_game_sound(GameSounds::TARGET_FAIL)); + } + return; + } + } + + // check if entry is already on complete escort list bool found = false; for (auto &es : Escort_ships) { - if (es.obj_signature == Objects[objnum].signature) { + if (es.obj_signature == obj.signature) { found = true; break; } } - // add new ship into complete list + // add new entry into complete list if ( !found ) { escort_info einfo; einfo.objnum = objnum; - einfo.obj_signature = Objects[objnum].signature; - einfo.priority = Ships[Objects[objnum].instance].escort_priority; + einfo.obj_signature = obj.signature; + einfo.priority = is_ship ? Ships[obj.instance].escort_priority : cp_ptr->escort_priority; - // add him to escort list and re-sort Escort_ships.push_back(einfo); Escort_ships.sort(escort_compare); - Ships[Objects[objnum].instance].flags.set(Ship::Ship_Flags::Escort); + if (is_ship) { + Ships[obj.instance].flags.set(Ship::Ship_Flags::Escort); + } } if (hud_escort_num_ships_on_list() > MAX_COMPLETE_ESCORT_LIST) { @@ -891,7 +968,8 @@ void hud_add_remove_ship_escort(int objnum, int supress_feedback) return; } - if ( Objects[objnum].type != OBJ_SHIP ) { + const auto otype = Objects[objnum].type; + if (otype != OBJ_SHIP && otype != OBJ_COORDINATE_POINT) { if ( !supress_feedback ) { snd_play( gamesnd_get_game_sound(GameSounds::TARGET_FAIL)); } @@ -994,7 +1072,11 @@ void hud_escort_target_next() objnum = eship->objnum; set_target_objnum( Player_ai, objnum); - hud_restore_subsystem_target(&Ships[Objects[objnum].instance]); + + // hud_restore_subsystem_target is ship-only; skip for non-ship entries (coordinate points etc.). + if (Objects[objnum].type == OBJ_SHIP && Objects[objnum].instance >= 0) { + hud_restore_subsystem_target(&Ships[Objects[objnum].instance]); + } } // return the number of ships currently on the escort list diff --git a/code/hud/hudlock.cpp b/code/hud/hudlock.cpp index a1dc1407f4e..79e4f5c24e5 100644 --- a/code/hud/hudlock.cpp +++ b/code/hud/hudlock.cpp @@ -482,7 +482,7 @@ int hud_abort_lock() if ( (Player_ai->target_objnum >= 0) ) { target_team = obj_team(&Objects[Player_ai->target_objnum]); - if ( ( Player_ship->team == target_team) && ( !iff_x_attacks_y(Player_ship->team, target_team) ) + if ( ( Player_ship->team == target_team) && ( !iff_x_attacks_y(Player_ship->team, target_team) ) && !weapon_has_iff_restrictions(wip)) { // if we're in multiplayer dogfight, ignore this // remember to check if we're firing a missile that doesn't require a current target diff --git a/code/hud/hudtarget.cpp b/code/hud/hudtarget.cpp index 6d2c7711582..22dabebd79d 100644 --- a/code/hud/hudtarget.cpp +++ b/code/hud/hudtarget.cpp @@ -30,6 +30,7 @@ #include "hud/hudtargetbox.h" #include "iff_defs/iff_defs.h" #include "io/timer.h" +#include "coordinate_points/coordinate_point.h" #include "jumpnode/jumpnode.h" #include "localization/localize.h" #include "mission/missionhotkey.h" @@ -2465,6 +2466,14 @@ int object_targetable_in_reticle(object *target_objp) if (!jnp->IsHidden()) return 1; } + else if ( obj_type == OBJ_COORDINATE_POINT ) + { + // Only target-in-front-able when the designer marked the point as visible in mission. + auto* cp = find_coordinate_point_by_objnum(OBJ_INDEX(target_objp)); + if (cp != nullptr && cp->flags[CoordinatePoint::Flags::Visible_in_mission]) { + return 1; + } + } return 0; } @@ -2551,6 +2560,10 @@ void hud_target_in_reticle_new() mc.model_num = jnp->GetModelNumber(); } break; + case OBJ_COORDINATE_POINT: + // No model to ray-cast against; fall through to the point-distance path below. + mc.model_num = -1; + break; default: Int3(); // Illegal object type. } diff --git a/code/hud/hudtargetbox.cpp b/code/hud/hudtargetbox.cpp index fb1237cecf1..cf9e6ed47f8 100644 --- a/code/hud/hudtargetbox.cpp +++ b/code/hud/hudtargetbox.cpp @@ -19,6 +19,7 @@ #include "hud/hudtargetbox.h" #include "iff_defs/iff_defs.h" #include "io/timer.h" +#include "coordinate_points/coordinate_point.h" #include "jumpnode/jumpnode.h" #include "localization/localize.h" #include "mission/missionparse.h" @@ -451,6 +452,10 @@ void HudGaugeTargetBox::render(float frametime, bool config) renderTargetJumpNode(target_objp); break; + case OBJ_COORDINATE_POINT: + renderTargetCoordinatePoint(target_objp); + break; + default: hud_cease_targeting(); break; @@ -1385,6 +1390,39 @@ void HudGaugeTargetBox::renderTargetJumpNode(object *target_objp) } } +void HudGaugeTargetBox::renderTargetCoordinatePoint(object *target_objp) +{ + auto* cp = find_coordinate_point_by_objnum(OBJ_INDEX(target_objp)); + if (cp == nullptr) { + set_target_objnum(Player_ai, -1); + return; + } + + // Coordinate points have no model and no integrity; just draw the frame, name, group, distance. + renderTargetForeground(); + renderTargetIntegrity(1); + setGaugeColor(); + + renderString(position[0] + Name_offsets[0], position[1] + Name_offsets[1], EG_TBOX_NAME, cp->name.c_str()); + + if (!cp->group.empty()) { + renderString(position[0] + Class_offsets[0], position[1] + Class_offsets[1], EG_TBOX_CLASS, cp->group.c_str()); + } + + float dist = Player_ai->current_target_distance; + if (Hud_unit_multiplier > 0.0f) { + dist = dist * Hud_unit_multiplier; + } + + char outstr[256]; + sprintf(outstr, XSTR("d: %.0f", 340), dist); + hud_num_make_mono(outstr, font_num); + + int w, h; + gr_get_string_size(&w, &h, outstr); + renderPrintfWithGauge(position[0] + Dist_offsets[0], position[1] + Dist_offsets[1], EG_TBOX_DIST, 1.0f, false, "%s", outstr); +} + /** * Toggle through the valid targetbox modes * @@ -2124,6 +2162,7 @@ void HudGaugeTargetBox::showTargetData(float /*frametime*/, bool config) break; case OBJ_JUMP_NODE: + case OBJ_COORDINATE_POINT: return; default: diff --git a/code/hud/hudtargetbox.h b/code/hud/hudtargetbox.h index 323942a9570..cecbfe024ba 100644 --- a/code/hud/hudtargetbox.h +++ b/code/hud/hudtargetbox.h @@ -128,6 +128,7 @@ class HudGaugeTargetBox: public HudGauge // HUD_TARGET_MONITOR void renderTargetDebris(object *target_objp); void renderTargetAsteroid(object *target_objp); void renderTargetJumpNode(object *target_objp); + void renderTargetCoordinatePoint(object *target_objp); void renderTargetSetup(vec3d *camera_eye, matrix *camera_orient, fov_t zoom); void renderTargetClose(); void renderTargetForeground(bool config = false); diff --git a/code/menuui/techmenu.cpp b/code/menuui/techmenu.cpp index 407cd8c63db..b9a91f8dde9 100644 --- a/code/menuui/techmenu.cpp +++ b/code/menuui/techmenu.cpp @@ -322,7 +322,7 @@ void techroom_select_new_entry() if ((i < Cur_entry + 5) && (i > Cur_entry - 5) ) continue; - mprintf(("TECH ROOM: Dumping excess ship textures...\n")); + mprintf(("TECH ROOM: Dumping excess textures...\n")); model_page_out_textures(list_entry.model_num); @@ -346,6 +346,44 @@ void techroom_select_new_entry() Trackball_mode = 0; + // Intel entries can specify a tech model and, if present, it takes priority over the animation + if (Tab == INTEL_DATA_TAB) { + intel_data* iip = &Intel_info[Cur_entry_index]; + + if (VALID_FNAME(iip->tech_model)) { + int i = 0; + + for (auto& list_entry : *Current_list) { + if ((list_entry.model_num > -1) && (list_entry.textures_loaded)) { + if ((i < Cur_entry + 5) && (i > Cur_entry - 5)) + continue; + + mprintf(("TECH ROOM: Dumping excess textures...\n")); + + model_page_out_textures(list_entry.model_num); + + list_entry.textures_loaded = 0; + } + i++; + } + + Techroom_modelnum = model_load(iip->tech_model, nullptr, ErrorType::WARNING); + + if (Techroom_modelnum >= 0) { + Current_list->at(Cur_entry).model_num = Techroom_modelnum; + + // intel index isn't a ship_info index; pass -1 so we skip ship-specific paging + model_page_in_textures(Techroom_modelnum); + + Current_list->at(Cur_entry).textures_loaded = 1; + + techroom_init_desc(Current_list->at(Cur_entry).desc, Tech_desc_coords[gr_screen.res][SHIP_W_COORD]); + fsspeech_play(FSSPEECH_FROM_TECHROOM, Current_list->at(Cur_entry).desc); + return; + } + } + } + // load animation here, we now only have one loaded int stream_result = generic_anim_init_and_stream(&Current_list->at(Cur_entry).animation, Current_list->at(Cur_entry).tech_anim_filename, bm_get_type(Tech_background_bitmap), true); @@ -544,6 +582,12 @@ void techroom_ships_render(float frametime) if (sip->flags[Ship::Info_Flags::No_lighting]) noLighting = true; + } else if (Tab == INTEL_DATA_TAB) { + intel_data* iip = &Intel_info[Cur_entry_index]; + + rev_rate *= 3.0f; + closeup_pos = iip->closeup_pos; + closeup_zoom = iip->closeup_zoom; } else { weapon_info* wip = &Weapon_info[Cur_entry_index]; @@ -1075,6 +1119,9 @@ static void intel_info_init(intel_data* inteli) inteli->name[0] = '\0'; inteli->desc = ""; inteli->anim_filename[0] = '\0'; + inteli->tech_model[0] = '\0'; + inteli->closeup_pos = vmd_zero_vector; + inteli->closeup_zoom = 0.5f; inteli->flags = IIF_DEFAULT_VALUE; inteli->custom_data = {}; } @@ -1132,6 +1179,39 @@ void parse_intel_table(const char* filename) stuff_string(intel_p->anim_filename, F_NAME, NAME_LENGTH); } + if (optional_string("$Tech Model:")) { + stuff_string(intel_p->tech_model, F_NAME, NAME_LENGTH); + + if (optional_string("+Closeup_pos:")) { + stuff_vec3d(&intel_p->closeup_pos); + } else if (VALID_FNAME(intel_p->tech_model)) { + // auto-calculate the closeup position from the model + int model_idx = model_load(intel_p->tech_model, nullptr, ErrorType::WARNING); + if (model_idx >= 0) { + polymodel *pm = model_get(model_idx); + + intel_p->closeup_pos.xyz.z = fabsf(pm->maxs.xyz.z); + + float temp = fabsf(pm->mins.xyz.z); + if (temp > intel_p->closeup_pos.xyz.z) + intel_p->closeup_pos.xyz.z = temp; + + intel_p->closeup_pos.xyz.z *= -2.0f; + + model_unload(model_idx); + } + } + + if (optional_string("+Closeup_zoom:")) { + stuff_float(&intel_p->closeup_zoom); + + if (intel_p->closeup_zoom <= 0.0f) { + mprintf(("Warning! Intel entry '%s' has a +Closeup_zoom value that is less than or equal to 0 (%f). Setting to default value.\n", intel_p->name, intel_p->closeup_zoom)); + intel_p->closeup_zoom = 0.5f; + } + } + } + if (optional_string("$AlwaysInTechRoom:")) { //Change this from stuff_int to stuff_boolean because it can only ever be 1 or 0 here - Mjn int temp; @@ -1481,7 +1561,11 @@ void techroom_do_frame(float frametime) break; case INTEL_DATA_TAB: - techroom_anim_render(frametime); + if (Techroom_modelnum >= 0) { + techroom_ships_render(frametime); + } else { + techroom_anim_render(frametime); + } break; } diff --git a/code/menuui/techmenu.h b/code/menuui/techmenu.h index 9cff6e9d273..5c9c6c74e8f 100644 --- a/code/menuui/techmenu.h +++ b/code/menuui/techmenu.h @@ -21,6 +21,9 @@ typedef struct intel_data { char anim_filename[NAME_LENGTH]; int flags; SCP_map custom_data; + char tech_model[NAME_LENGTH]; + vec3d closeup_pos; + float closeup_zoom; } intel_data; // flags by Goober5000 diff --git a/code/mission/missionparse.cpp b/code/mission/missionparse.cpp index b5090b26fd0..78d20a56ea5 100644 --- a/code/mission/missionparse.cpp +++ b/code/mission/missionparse.cpp @@ -66,6 +66,7 @@ #include "parse/parselo.h" #include "parse/sexp_container.h" #include "prop/prop.h" +#include "coordinate_points/coordinate_point.h" #include "scripting/global_hooks.h" #include "scripting/hook_api.h" #include "scripting/hook_conditions.h" @@ -643,6 +644,17 @@ parse_object_flag_description Parse_prop_flag_descr const size_t Num_parse_prop_flags = sizeof(Parse_prop_flags) / sizeof(flag_def_list_new); const size_t Num_parse_prop_flag_descriptions = sizeof(Parse_prop_flag_descriptions) / sizeof(parse_object_flag_description); +flag_def_list_new Parse_coordinate_point_flags[] = { + { "visible_in_mission", CoordinatePoint::Flags::Visible_in_mission, true, false }, +}; + +parse_object_flag_description Parse_coordinate_point_flag_descriptions[] = { + { CoordinatePoint::Flags::Visible_in_mission, "Render the shape in-game so the player can see and target it, not just in the editor."}, +}; + +const size_t Num_parse_coordinate_point_flags = sizeof(Parse_coordinate_point_flags) / sizeof(flag_def_list_new); +const size_t Num_parse_coordinate_point_flag_descriptions = sizeof(Parse_coordinate_point_flag_descriptions) / sizeof(parse_object_flag_description); + // These are only the flags that are saved to the mission file. See the MEF_ #defines. flag_def_list Mission_event_flags[] = { { "interval & delay use msecs", MEF_USE_MSECS, 0 }, @@ -5421,9 +5433,9 @@ void parse_wings(mission* pm) { required_string("#Wings"); while (true) { - int which = required_string_one_of(3, "#Events", "#Props", "$Name:"); + int which = required_string_one_of(4, "#Events", "#Props", "#Coordinate Points", "$Name:"); - if (which == -1 || which == 0 || which == 1) // #Events or #Props + if (which == -1 || which == 0 || which == 1 || which == 2) // any section terminator break; Assert(Num_wings < MAX_WINGS); @@ -5435,12 +5447,139 @@ void parse_wings(mission* pm) void parse_props(mission* pm) { if (optional_string("#Props")) { - while (required_string_either("#Events", "$Name:")) { + while (true) { + int which = required_string_one_of(3, "#Coordinate Points", "#Events", "$Name:"); + if (which == -1 || which == 0 || which == 1) { // #Coordinate Points or #Events + break; + } parse_prop(pm); } } } +void parse_coordinate_point(mission* /*pm*/) +{ + parsed_coordinate_point cp; + + required_string("$Name:"); + stuff_string(cp.name, F_NAME); + + required_string("$Location:"); + stuff_vec3d(&cp.position); + + if (optional_string("+Group:")) { + stuff_string(cp.group, F_NAME); + } + + if (optional_string("+Color:")) { + // rgba is pre-initialized so a three-value list (R G B) still leaves alpha at 255; + // stuff_int_list parses up to 4 ints and leaves trailing entries untouched. + int rgba[4] = { 255, 255, 255, 255 }; + stuff_int_list(rgba, 4, ParseLookupType::RAW_INTEGER_TYPE); + for (int& c : rgba) { + CLAMP(c, 0, 255); + } + gr_init_alphacolor(&cp.display_color, rgba[0], rgba[1], rgba[2], rgba[3]); + } + + if (optional_string("+Shape:")) { + SCP_string shape_name; + stuff_string(shape_name, F_NAME); + + // Built-in primitive kinds. + if (!stricmp(shape_name.c_str(), "NGon")) { + cp.shape_kind = CoordinatePointShapeKind::NGon; + } else if (!stricmp(shape_name.c_str(), "Star")) { + cp.shape_kind = CoordinatePointShapeKind::Star; + } else if (!stricmp(shape_name.c_str(), "Triangle")) { + // Legacy alias from before the shape-kind refactor. Equivalent to NGon(3). + cp.shape_kind = CoordinatePointShapeKind::NGon; + cp.shape_sides = 3; + } else if (!stricmp(shape_name.c_str(), "Diamond")) { + // Legacy alias: NGon(4) with point-up convention (the default). + cp.shape_kind = CoordinatePointShapeKind::NGon; + cp.shape_sides = 4; + } else if (!stricmp(shape_name.c_str(), "Pentagon")) { + cp.shape_kind = CoordinatePointShapeKind::NGon; + cp.shape_sides = 5; + } else { + // Anything else: treat as a tabled shape name; index resolved at post-process. + cp.shape_kind = CoordinatePointShapeKind::Tabled; + cp.shape_table_name = shape_name; + } + } + + if (optional_string("+Sides:")) { + stuff_int(&cp.shape_sides); + CLAMP(cp.shape_sides, NGON_SIDES_MIN, NGON_SIDES_MAX); + } + + if (optional_string("+Points:")) { + stuff_int(&cp.shape_points); + CLAMP(cp.shape_points, STAR_POINTS_MIN, STAR_POINTS_MAX); + } + + if (optional_string("+Inner Radius:")) { + stuff_float(&cp.shape_inner_radius); + CLAMP(cp.shape_inner_radius, STAR_INNER_MIN, STAR_INNER_MAX); + } + + if (optional_string("+Angle:")) { + stuff_float(&cp.shape_angle_deg); + } + + if (optional_string("+Size:")) { + stuff_float(&cp.size_scale); + CLAMP(cp.size_scale, COORDINATE_POINT_SIZE_MIN, COORDINATE_POINT_SIZE_MAX); + } + + if (optional_string("+Escort Priority:")) { + stuff_int(&cp.escort_priority); + if (cp.escort_priority < 0) { + cp.escort_priority = 0; + } + } + + if (optional_string("+Multi Team:")) { + stuff_int(&cp.multi_team); + if (cp.multi_team < -1 || cp.multi_team >= MAX_TVT_TEAMS) { + cp.multi_team = -1; + } + } + + if (optional_string("+Flags:")) { + SCP_vector unparsed; + parse_string_flag_list(cp.flags, Parse_coordinate_point_flags, Num_parse_coordinate_point_flags, &unparsed); + if (!unparsed.empty()) { + for (const auto& f : unparsed) { + WarningEx(LOCATION, "Unknown flag in coordinate point flags: %s", f.c_str()); + } + } + } + + if (optional_string("+Layer:")) { + stuff_string(cp.fred_layer, F_NAME); + if (!mission_has_layer_name(&The_mission, cp.fred_layer)) { + if (cp.fred_layer.empty()) { + cp.fred_layer = "Default"; + } else { + The_mission.fred_layers.push_back(cp.fred_layer); + } + } + } + + Parse_coordinate_points.emplace_back(std::move(cp)); +} + +void parse_coordinate_points(mission* pm) +{ + if (optional_string("#Coordinate Points")) { + while (required_string_either("#Events", "$Name:")) { + parse_coordinate_point(pm); + } + } +} + // Goober5000 void resolve_and_check_anchor(bool check_for_hangar, SCP_set &anchors_checked, anchor_t &anchor, const char *other_name, bool other_is_ship, bool is_arrival) { @@ -6921,6 +7060,7 @@ bool parse_mission(mission *pm, int flags) parse_objects(pm, flags); parse_wings(pm); parse_props(pm); + parse_coordinate_points(pm); parse_events(pm); parse_goals(pm); parse_waypoints_and_jumpnodes(pm); @@ -7011,6 +7151,7 @@ bool post_process_mission(mission *pm) ship_obj *so; post_process_mission_props(); + post_process_mission_coordinate_points(); // Goober5000 - this must be done even before post_process_ships_wings because it is a prerequisite ship_clear_ship_type_counts(); @@ -7482,6 +7623,7 @@ void mission_init(mission *pm, bool quick_init) jumpnode_level_close(); waypoint_level_close(); props_level_close(); + coordinate_points_level_close(); red_alert_invalidate_timestamp(); event_music_reset_choices(); @@ -7514,6 +7656,7 @@ void mission_init(mission *pm, bool quick_init) Reinforcements.clear(); Parse_props.clear(); + Parse_coordinate_points.clear(); Asteroid_field.num_initial_asteroids = 0; diff --git a/code/mission/missionparse.h b/code/mission/missionparse.h index 8e917787108..f8b12dd7fa0 100644 --- a/code/mission/missionparse.h +++ b/code/mission/missionparse.h @@ -15,6 +15,7 @@ #include "ai/ai.h" #include "ai/ai_profiles.h" +#include "coordinate_points/coordinate_point_flags.h" #include "globalincs/version.h" #include "graphics/2d.h" #include "io/keycontrol.h" @@ -338,6 +339,10 @@ extern flag_def_list_new Parse_prop_flags[]; extern parse_object_flag_description Parse_prop_flag_descriptions[]; extern const size_t Num_parse_prop_flags; extern const size_t Num_parse_prop_flag_descriptions; +extern flag_def_list_new Parse_coordinate_point_flags[]; +extern parse_object_flag_description Parse_coordinate_point_flag_descriptions[]; +extern const size_t Num_parse_coordinate_point_flags; +extern const size_t Num_parse_coordinate_point_flag_descriptions; extern const char *Icon_names[]; extern const char *Mission_event_log_flags[]; diff --git a/code/missioneditor/missionsave.cpp b/code/missioneditor/missionsave.cpp index 7941def3103..708049699d7 100644 --- a/code/missioneditor/missionsave.cpp +++ b/code/missioneditor/missionsave.cpp @@ -43,6 +43,8 @@ #include "parse/sexp.h" #include "parse/sexp_container.h" #include "prop/prop.h" +#include "coordinate_points/coordinate_point.h" +#include "coordinate_points/coordinate_shapes.h" #include "sound/ds.h" #include "sound/sound.h" #include "starfield/nebula.h" @@ -3232,6 +3234,8 @@ void Fred_mission_save::save_mission_internal(const char* pathname) err = -8; } else if (save_props()) { err = -18; + } else if (save_coordinate_points()) { + err = -19; } else if (save_events()) { err = -9; } else if (save_goals()) { @@ -5427,3 +5431,176 @@ int Fred_mission_save::save_props() fso_comment_pop(true); return err; } + +int Fred_mission_save::save_coordinate_points() +{ + if (save_config.save_format == MissionFormat::RETAIL || Coordinate_points.empty()) { + return err; + } + + fred_parse_flag = 0; + size_t count = 0; + + if (optional_string_fred("#Coordinate Points", "#End")) { + parse_comments(2); + } else { + fout("\n\n#Coordinate Points"); + } + fout("\t\t;! " SIZE_T_ARG " total\n", Coordinate_points.size()); + + for (const auto& cp : Coordinate_points) { + if (cp.objnum < 0) { + continue; + } + + required_string_either_fred("$Name:", "#Events"); + required_string_fred("$Name:"); + parse_comments(count ? 2 : 1); + fout(" %s", cp.name.c_str()); + + count++; + + required_string_fred("\n$Location:"); + parse_comments(0); + save_vector(Objects[cp.objnum].pos); + + if (!cp.group.empty()) { + if (optional_string_fred("+Group:", "$Name:")) { + parse_comments(); + } else { + fout("\n+Group:"); + } + fout(" %s", cp.group.c_str()); + } + + const bool color_is_default = + (cp.display_color.red == 255 && cp.display_color.green == 255 && + cp.display_color.blue == 255 && cp.display_color.alpha == 255); + if (!color_is_default) { + if (optional_string_fred("+Color:", "$Name:")) { + parse_comments(); + } else { + fout("\n+Color:"); + } + fout(" ( %d %d %d %d )", cp.display_color.red, cp.display_color.green, + cp.display_color.blue, cp.display_color.alpha); + } + + // +Shape: omit when it's the default (NGon with default sides). For Tabled we look + // up the registered name; if the index is somehow stale, just skip the line. + const char* shape_name = nullptr; + if (cp.shape_kind == CoordinatePointShapeKind::Star) { + shape_name = "Star"; + } else if (cp.shape_kind == CoordinatePointShapeKind::Tabled && + cp.shape_table_index >= 0 && + cp.shape_table_index < static_cast(Coordinate_shapes.size())) { + shape_name = Coordinate_shapes[cp.shape_table_index].name.c_str(); + } + if (shape_name != nullptr) { + if (optional_string_fred("+Shape:", "$Name:")) { + parse_comments(); + } else { + fout("\n+Shape:"); + } + fout(" %s", shape_name); + } + + // +Sides: only emit for NGon, only when non-default. + if (cp.shape_kind == CoordinatePointShapeKind::NGon && cp.shape_sides != 4) { + if (optional_string_fred("+Sides:", "$Name:")) { + parse_comments(); + } else { + fout("\n+Sides:"); + } + fout(" %d", cp.shape_sides); + } + + // +Points: / +Inner Radius: only for Star, only when non-default. + if (cp.shape_kind == CoordinatePointShapeKind::Star && cp.shape_points != 5) { + if (optional_string_fred("+Points:", "$Name:")) { + parse_comments(); + } else { + fout("\n+Points:"); + } + fout(" %d", cp.shape_points); + } + + if (cp.shape_kind == CoordinatePointShapeKind::Star && cp.shape_inner_radius != STAR_INNER_DEFAULT) { + if (optional_string_fred("+Inner Radius:", "$Name:")) { + parse_comments(); + } else { + fout("\n+Inner Radius:"); + } + fout(" %f", cp.shape_inner_radius); + } + + // +Angle: applies to every kind; emit when non-zero. + if (cp.shape_angle_deg != 0.0f) { + if (optional_string_fred("+Angle:", "$Name:")) { + parse_comments(); + } else { + fout("\n+Angle:"); + } + fout(" %f", cp.shape_angle_deg); + } + + if (cp.size_scale != 1.0f) { + if (optional_string_fred("+Size:", "$Name:")) { + parse_comments(); + } else { + fout("\n+Size:"); + } + fout(" %f", cp.size_scale); + } + + if (cp.escort_priority > 0) { + if (optional_string_fred("+Escort Priority:", "$Name:")) { + parse_comments(); + } else { + fout("\n+Escort Priority:"); + } + fout(" %d", cp.escort_priority); + } + + if (cp.multi_team >= 0) { + if (optional_string_fred("+Multi Team:", "$Name:")) { + parse_comments(); + } else { + fout("\n+Multi Team:"); + } + fout(" %d", cp.multi_team); + } + + if (cp.flags.any_set()) { + if (optional_string_fred("+Flags:", "$Name:")) { + parse_comments(); + fout(" ("); + } else { + fout("\n+Flags: ("); + } + for (size_t fi = 0; fi < Num_parse_coordinate_point_flags; ++fi) { + const auto& def = Parse_coordinate_point_flags[fi]; + if (cp.flags[def.def]) { + fout(" \"%s\"", def.name); + } + } + fout(" )"); + } + + if (save_config.save_format != MissionFormat::RETAIL && + !cp.fred_layer.empty() && + !lcase_equal(cp.fred_layer, "Default")) { + if (optional_string_fred("+Layer:", "$Name:")) { + parse_comments(); + } else { + fout("\n+Layer:"); + } + fout(" %s", cp.fred_layer.c_str()); + } + + fso_comment_pop(); + } + + fso_comment_pop(true); + return err; +} diff --git a/code/missioneditor/missionsave.h b/code/missioneditor/missionsave.h index 7d8f1558344..b641d9cecb2 100644 --- a/code/missioneditor/missionsave.h +++ b/code/missioneditor/missionsave.h @@ -566,6 +566,8 @@ class Fred_mission_save { */ int save_props(); + int save_coordinate_points(); + /** * @brief Utility function to save a raw comment, the start of which precedes the current raw_ptr, to a file while * handling newlines properly diff --git a/code/missioneditor/sexp_tree_opf.cpp b/code/missioneditor/sexp_tree_opf.cpp index 331318f65e0..aa3e3c2d404 100644 --- a/code/missioneditor/sexp_tree_opf.cpp +++ b/code/missioneditor/sexp_tree_opf.cpp @@ -3,6 +3,7 @@ #include "parse/sexp.h" #include "globalincs/linklist.h" +#include "coordinate_points/coordinate_point.h" #include "object/object.h" #include "object/waypoint.h" #include "ship/ship.h" @@ -229,6 +230,18 @@ sexp_list_item *SexpTreeOPF::get_listing_opf_point() return head.next; } +sexp_list_item *SexpTreeOPF::get_listing_opf_coordinate_point() +{ + sexp_list_item head; + + for (const auto &cp: Coordinate_points) + { + head.add_data(cp.name.c_str()); + } + + return head.next; +} + sexp_list_item *SexpTreeOPF::get_listing_opf_iff() { sexp_list_item head; @@ -736,6 +749,7 @@ sexp_list_item *SexpTreeOPF::get_listing_opf_ship_wing_point() const head.add_list(get_listing_opf_ship()); head.add_list(get_listing_opf_wing()); head.add_list(get_listing_opf_point()); + head.add_list(get_listing_opf_coordinate_point()); return head.next; } @@ -2067,6 +2081,10 @@ sexp_list_item *SexpTreeOPF::get_listing_opf(int opf, int parent_node, int arg_i list = get_listing_opf_point(); break; + case OPF_COORDINATE_POINT: + list = get_listing_opf_coordinate_point(); + break; + case OPF_IFF: list = get_listing_opf_iff(); break; @@ -2560,6 +2578,7 @@ int SexpTreeOPF::query_default_argument_available(int op, int i) const case OPF_PROP_CLASS_NAME: case OPF_HUGE_WEAPON: case OPF_JUMP_NODE_NAME: + case OPF_COORDINATE_POINT: case OPF_AMBIGUOUS: case OPF_CARGO: case OPF_ARRIVAL_LOCATION: @@ -3182,6 +3201,10 @@ int SexpTreeOPF::get_default_value(sexp_list_item* item, int op, int i) const str = ""; break; + case OPF_COORDINATE_POINT: + str = ""; + break; + case OPF_NAV_POINT: str = "