Compare commits
No commits in common. "main" and "dev" have entirely different histories.
15
.forgejo/FUNDING.yml
Normal file
15
.forgejo/FUNDING.yml
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
# These are supported funding model platforms
|
||||
|
||||
github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
|
||||
patreon: Cryoforge_Nexus
|
||||
open_collective: # Replace with a single Open Collective username
|
||||
ko_fi:
|
||||
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
|
||||
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
|
||||
liberapay: # Replace with a single Liberapay username
|
||||
issuehunt: # Replace with a single IssueHunt username
|
||||
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
|
||||
polar: # Replace with a single Polar username
|
||||
buy_me_a_coffee: # Replace with a single Buy Me a Coffee username
|
||||
thanks_dev: # Replace with a single thanks.dev username
|
||||
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
|
||||
9
.forgejo/cla-signatures.json
Normal file
9
.forgejo/cla-signatures.json
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"signatures": [
|
||||
{
|
||||
"github_username": "Serkyo",
|
||||
"signed_at": "2026-07-09T22:59:00.073Z",
|
||||
"pull_request": "https://github.com/Cryoforge-Nexus/Synvael/pull/3"
|
||||
}
|
||||
]
|
||||
}
|
||||
28
.forgejo/scripts/check-lfs-pointers.sh
Executable file
28
.forgejo/scripts/check-lfs-pointers.sh
Executable file
|
|
@ -0,0 +1,28 @@
|
|||
#!/usr/bin/env bash
|
||||
# Verifies that every LFS-tracked path is committed as a Git LFS pointer rather than as a raw binary.
|
||||
#
|
||||
# Requires a checkout performed without LFS smudging: a raw-committed binary is only distinguishable from a proper pointer at the object level, not in a smudged working tree.
|
||||
|
||||
set -eu
|
||||
|
||||
# Every tracked path whose .gitattributes filter resolves to lfs must be committed as an LFS pointer. A binary committed in its place (e.g. by a contributor without git-lfs installed) is the failure this guard catches.
|
||||
should_be_lfs=$(git ls-files | git check-attr --stdin filter | sed -n 's/: filter: lfs$//p')
|
||||
|
||||
violations=""
|
||||
while IFS= read -r f; do
|
||||
[ -z "$f" ] && continue
|
||||
first_line=$(git cat-file -p "HEAD:$f" | head -n1)
|
||||
if [ "$first_line" != "version https://git-lfs.github.com/spec/v1" ]; then
|
||||
violations="$violations $f"
|
||||
fi
|
||||
done <<< "$should_be_lfs"
|
||||
|
||||
if [ -n "$violations" ]; then
|
||||
echo "::error::Files match an LFS pattern in .gitattributes but were committed as raw blobs instead of Git LFS pointers:"
|
||||
# Unquoted expansion is intentional: the accumulated list is split on whitespace back into individual paths.
|
||||
for f in $violations; do echo " - $f"; done
|
||||
echo "Fix: install git-lfs, run 'git lfs install', then 're-add' each file with 'git add --renormalize <file>' and recommit."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "All LFS-tracked files are stored as pointers."
|
||||
174
.forgejo/scripts/cla-check.js
Normal file
174
.forgejo/scripts/cla-check.js
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
// Enforces the Contributor License Agreement on pull requests.
|
||||
//
|
||||
// Invoked from .github/workflows/cla.yml through actions/github-script, which supplies the `github`, `context` and `core` helpers as arguments. Kept in a file rather than inline in the workflow so that no `${{ }}` expression is ever interpolated into executable code: this workflow runs on `pull_request_target` with write permissions, where an injected expression would execute against repository credentials.
|
||||
|
||||
module.exports = async ({ github, context, core }) => {
|
||||
const fs = require('fs');
|
||||
|
||||
const SIGNATURES_FILE = '.github/cla-signatures.json';
|
||||
const CLA_AGREE_PATTERN = /I have read the CLA and I agree/i;
|
||||
const CLA_LINK = `https://github.com/${context.repo.owner}/${context.repo.repo}/blob/dev/CLA.md`;
|
||||
const CHECK_NAME = 'CLA Signed';
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
function loadSignatures() {
|
||||
try {
|
||||
const raw = fs.readFileSync(SIGNATURES_FILE, 'utf8');
|
||||
return JSON.parse(raw);
|
||||
} catch {
|
||||
return { signatures: [] };
|
||||
}
|
||||
}
|
||||
|
||||
function saveSignatures(data) {
|
||||
fs.writeFileSync(SIGNATURES_FILE, JSON.stringify(data, null, 2) + '\n');
|
||||
}
|
||||
|
||||
function hasSigned(data, username) {
|
||||
return data.signatures.some(
|
||||
(s) => s.github_username.toLowerCase() === username.toLowerCase()
|
||||
);
|
||||
}
|
||||
|
||||
async function getAuthors(prNumber) {
|
||||
const commits = await github.paginate(
|
||||
github.rest.pulls.listCommits,
|
||||
{ owner: context.repo.owner, repo: context.repo.repo, pull_number: prNumber }
|
||||
);
|
||||
const authors = new Set();
|
||||
for (const c of commits) {
|
||||
if (c.author && c.author.login) {
|
||||
authors.add(c.author.login);
|
||||
}
|
||||
}
|
||||
return [...authors];
|
||||
}
|
||||
|
||||
async function setStatus(sha, state, description) {
|
||||
await github.rest.repos.createCommitStatus({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
sha,
|
||||
state,
|
||||
description,
|
||||
context: CHECK_NAME,
|
||||
});
|
||||
}
|
||||
|
||||
async function commitSignatures(username) {
|
||||
// Stage, commit, and push the updated signatures file using the GitHub API (create-or-update-file-contents).
|
||||
const content = fs.readFileSync(SIGNATURES_FILE, 'utf8');
|
||||
const encoded = Buffer.from(content).toString('base64');
|
||||
|
||||
let sha;
|
||||
try {
|
||||
const existing = await github.rest.repos.getContent({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
path: SIGNATURES_FILE,
|
||||
ref: context.payload.repository.default_branch,
|
||||
});
|
||||
sha = existing.data.sha;
|
||||
} catch {
|
||||
// File does not exist yet; will be created.
|
||||
}
|
||||
|
||||
await github.rest.repos.createOrUpdateFileContents({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
path: SIGNATURES_FILE,
|
||||
message: `chore(workspace): record CLA signature for @${username}`,
|
||||
content: encoded,
|
||||
sha,
|
||||
branch: context.payload.repository.default_branch,
|
||||
});
|
||||
}
|
||||
|
||||
// --- Main logic ---
|
||||
|
||||
let prNumber;
|
||||
let headSha;
|
||||
|
||||
if (context.eventName === 'pull_request_target') {
|
||||
prNumber = context.payload.pull_request.number;
|
||||
headSha = context.payload.pull_request.head.sha;
|
||||
} else {
|
||||
// issue_comment on a PR
|
||||
prNumber = context.payload.issue.number;
|
||||
// Fetch the PR to get the head SHA.
|
||||
const pr = await github.rest.pulls.get({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: prNumber,
|
||||
});
|
||||
headSha = pr.data.head.sha;
|
||||
}
|
||||
|
||||
const sigData = loadSignatures();
|
||||
const authors = await getAuthors(prNumber);
|
||||
|
||||
// If this is a comment event, check if the commenter is signing.
|
||||
if (context.eventName === 'issue_comment') {
|
||||
const comment = context.payload.comment.body;
|
||||
const commenter = context.payload.comment.user.login;
|
||||
|
||||
if (CLA_AGREE_PATTERN.test(comment) && authors.includes(commenter)) {
|
||||
if (!hasSigned(sigData, commenter)) {
|
||||
sigData.signatures.push({
|
||||
github_username: commenter,
|
||||
signed_at: new Date().toISOString(),
|
||||
pull_request: `https://github.com/${context.repo.owner}/${context.repo.repo}/pull/${prNumber}`,
|
||||
});
|
||||
saveSignatures(sigData);
|
||||
await commitSignatures(commenter);
|
||||
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: prNumber,
|
||||
body: `✅ @${commenter} — CLA signature recorded. Thank you!`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Re-read signatures (may have been updated above).
|
||||
const currentSigs = loadSignatures();
|
||||
const unsigned = authors.filter((a) => !hasSigned(currentSigs, a));
|
||||
|
||||
if (unsigned.length === 0) {
|
||||
await setStatus(headSha, 'success', 'All authors have signed the CLA.');
|
||||
core.info('All PR authors have signed the CLA.');
|
||||
} else {
|
||||
await setStatus(headSha, 'pending', `CLA not signed by: ${unsigned.join(', ')}`);
|
||||
|
||||
// Only post the instructions comment on PR open/reopen, not on every push or unrelated comment.
|
||||
if (
|
||||
context.eventName === 'pull_request_target' &&
|
||||
['opened', 'reopened'].includes(context.payload.action)
|
||||
) {
|
||||
const mention = unsigned.map((u) => `@${u}`).join(', ');
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: prNumber,
|
||||
body: [
|
||||
`### 📝 CLA Signature Required`,
|
||||
'',
|
||||
`${mention} — thank you for your contribution! Before this pull request can be reviewed and merged, all commit authors must sign the [Contributor License Agreement](${CLA_LINK}).`,
|
||||
'',
|
||||
'To sign, please leave a comment on this pull request containing **exactly**:',
|
||||
'',
|
||||
'```',
|
||||
'I have read the CLA and I agree',
|
||||
'```',
|
||||
'',
|
||||
'Signing is a one-time action. Once recorded, all future pull requests from the same account are accepted automatically.',
|
||||
].join('\n'),
|
||||
});
|
||||
}
|
||||
|
||||
core.info(`Unsigned authors: ${unsigned.join(', ')}`);
|
||||
}
|
||||
};
|
||||
14
.forgejo/scripts/run-selene.sh
Executable file
14
.forgejo/scripts/run-selene.sh
Executable file
|
|
@ -0,0 +1,14 @@
|
|||
#!/usr/bin/env bash
|
||||
# Downloads a pinned selene release and lints the repository's Lua sources.
|
||||
#
|
||||
# selene publishes no maintained GitHub Action, so the linter binary is fetched per run. The version is pinned so that an upstream release cannot change CI results without a deliberate commit here.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SELENE_VERSION="0.30.1"
|
||||
SELENE_URL="https://github.com/Kampfkarren/selene/releases/download/${SELENE_VERSION}/selene-light-${SELENE_VERSION}-linux.zip"
|
||||
|
||||
curl -sL "$SELENE_URL" -o selene.zip
|
||||
unzip -q selene.zip
|
||||
chmod +x selene
|
||||
./selene assets/scripts/ mods/
|
||||
94
.forgejo/workflows/ci.yml
Normal file
94
.forgejo/workflows/ci.yml
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ "dev" ]
|
||||
pull_request:
|
||||
branches: [ "dev", "main" ]
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
CARGO_BUILD_JOBS: 1
|
||||
CMAKE_BUILD_PARALLEL_LEVEL: 1
|
||||
|
||||
jobs:
|
||||
rust-lint:
|
||||
name: Rust Check & Lint
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Install git-lfs
|
||||
run: apt-get update && apt-get install -y git-lfs
|
||||
- uses: https://github.com/actions/checkout@v4
|
||||
with:
|
||||
lfs: true
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: https://github.com/dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
components: clippy, rustfmt
|
||||
|
||||
- name: Cache cargo artifacts
|
||||
uses: https://github.com/Swatinem/rust-cache@v2
|
||||
|
||||
- name: Install shader dependencies
|
||||
run: apt-get update && apt-get install -y build-essential cmake python3 ninja-build
|
||||
|
||||
- name: Check Rust Formatting
|
||||
run: cargo fmt --all -- --check
|
||||
|
||||
- name: Run Clippy
|
||||
run: cargo clippy --all-targets --all-features -- -D warnings
|
||||
|
||||
rust-test:
|
||||
name: Rust Tests
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Install git-lfs
|
||||
run: apt-get update && apt-get install -y git-lfs
|
||||
- uses: https://github.com/actions/checkout@v4
|
||||
with:
|
||||
lfs: true
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: https://github.com/dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Cache cargo artifacts
|
||||
uses: https://github.com/Swatinem/rust-cache@v2
|
||||
|
||||
- name: Install shader dependencies
|
||||
run: apt-get update && apt-get install -y build-essential cmake python3 ninja-build
|
||||
|
||||
- name: Run Tests
|
||||
run: cargo test --workspace --all-features
|
||||
|
||||
lua-lint:
|
||||
name: Lua Lint & Format
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Install git-lfs
|
||||
run: apt-get update && apt-get install -y git-lfs
|
||||
- uses: https://github.com/actions/checkout@v4
|
||||
|
||||
- name: Check Lua Formatting
|
||||
run: |
|
||||
curl -L -o stylua.zip https://github.com/JohnnyMorganz/StyLua/releases/latest/download/stylua-linux-x86_64.zip
|
||||
unzip stylua.zip
|
||||
chmod +x stylua
|
||||
./stylua --check assets/scripts/ mods/
|
||||
|
||||
- name: Run Selene
|
||||
run: ./.forgejo/scripts/run-selene.sh
|
||||
|
||||
lfs-guard:
|
||||
name: LFS Pointer Guard
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
# Checkout without smudging LFS content so the committed git objects can be inspected directly; a raw-committed binary is only distinguishable from a proper pointer at the object level, not in a smudged working tree.
|
||||
- name: Install git-lfs
|
||||
run: apt-get update && apt-get install -y git-lfs
|
||||
- uses: https://github.com/actions/checkout@v4
|
||||
with:
|
||||
lfs: false
|
||||
|
||||
- name: Verify LFS-tracked files are stored as pointers
|
||||
run: ./.forgejo/scripts/check-lfs-pointers.sh
|
||||
36
.forgejo/workflows/cla.yml
Normal file
36
.forgejo/workflows/cla.yml
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
name: CLA Check
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened, reopened, synchronize]
|
||||
issue_comment:
|
||||
types: [created]
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
statuses: write
|
||||
|
||||
jobs:
|
||||
cla-check:
|
||||
# Run on PR events, or on issue comments that are on PRs (not plain issues).
|
||||
if: >
|
||||
github.event_name == 'pull_request_target' ||
|
||||
(github.event_name == 'issue_comment' && github.event.issue.pull_request)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: https://github.com/actions/checkout@v4
|
||||
with:
|
||||
# pull_request_target runs on the base branch; check out the base
|
||||
# so the signatures file is from the canonical source.
|
||||
ref: ${{ github.event.pull_request.base.ref || github.event.repository.default_branch }}
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: CLA enforcement
|
||||
uses: https://github.com/actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
script: |
|
||||
const claCheck = require('${{ github.workspace }}/.forgejo/scripts/cla-check.js');
|
||||
await claCheck({ github, context, core });
|
||||
24
.forgejo/workflows/release.yml
Normal file
24
.forgejo/workflows/release.yml
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*.*.*"
|
||||
|
||||
jobs:
|
||||
create-release:
|
||||
name: Create GitHub Release
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
uses: https://github.com/actions/checkout@v4
|
||||
with:
|
||||
lfs: true
|
||||
|
||||
- name: Create Release
|
||||
uses: https://github.com/softprops/action-gh-release@v2
|
||||
with:
|
||||
generate_release_notes: true
|
||||
48
.gitattributes
vendored
Normal file
48
.gitattributes
vendored
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
# Enforce LF line endings repo-wide.
|
||||
* text=auto eol=lf
|
||||
|
||||
# --- Git LFS-tracked binary assets ---
|
||||
# Textures
|
||||
*.png filter=lfs diff=lfs merge=lfs -text
|
||||
*.jpg filter=lfs diff=lfs merge=lfs -text
|
||||
*.jpeg filter=lfs diff=lfs merge=lfs -text
|
||||
*.webp filter=lfs diff=lfs merge=lfs -text
|
||||
*.tga filter=lfs diff=lfs merge=lfs -text
|
||||
*.bmp filter=lfs diff=lfs merge=lfs -text
|
||||
*.gif filter=lfs diff=lfs merge=lfs -text
|
||||
*.tiff filter=lfs diff=lfs merge=lfs -text
|
||||
*.tif filter=lfs diff=lfs merge=lfs -text
|
||||
*.ktx filter=lfs diff=lfs merge=lfs -text
|
||||
*.ktx2 filter=lfs diff=lfs merge=lfs -text
|
||||
*.dds filter=lfs diff=lfs merge=lfs -text
|
||||
*.hdr filter=lfs diff=lfs merge=lfs -text
|
||||
*.exr filter=lfs diff=lfs merge=lfs -text
|
||||
# 3D models (binary formats only; OBJ / glTF JSON stay in regular Git)
|
||||
*.glb filter=lfs diff=lfs merge=lfs -text
|
||||
*.fbx filter=lfs diff=lfs merge=lfs -text
|
||||
*.blend filter=lfs diff=lfs merge=lfs -text
|
||||
*.blend1 filter=lfs diff=lfs merge=lfs -text
|
||||
*.usd filter=lfs diff=lfs merge=lfs -text
|
||||
*.usdc filter=lfs diff=lfs merge=lfs -text
|
||||
*.usdz filter=lfs diff=lfs merge=lfs -text
|
||||
*.stl filter=lfs diff=lfs merge=lfs -text
|
||||
# Audio
|
||||
*.ogg filter=lfs diff=lfs merge=lfs -text
|
||||
*.wav filter=lfs diff=lfs merge=lfs -text
|
||||
*.flac filter=lfs diff=lfs merge=lfs -text
|
||||
*.opus filter=lfs diff=lfs merge=lfs -text
|
||||
*.mp3 filter=lfs diff=lfs merge=lfs -text
|
||||
*.aiff filter=lfs diff=lfs merge=lfs -text
|
||||
# Fonts
|
||||
*.ttf filter=lfs diff=lfs merge=lfs -text
|
||||
*.otf filter=lfs diff=lfs merge=lfs -text
|
||||
*.woff filter=lfs diff=lfs merge=lfs -text
|
||||
*.woff2 filter=lfs diff=lfs merge=lfs -text
|
||||
# Misc binary content
|
||||
*.bin filter=lfs diff=lfs merge=lfs -text
|
||||
*.dat filter=lfs diff=lfs merge=lfs -text
|
||||
*.pak filter=lfs diff=lfs merge=lfs -text
|
||||
# Video (unlikely but cover it)
|
||||
*.mp4 filter=lfs diff=lfs merge=lfs -text
|
||||
*.webm filter=lfs diff=lfs merge=lfs -text
|
||||
*.mov filter=lfs diff=lfs merge=lfs -text
|
||||
40
.gitignore
vendored
Normal file
40
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
# Generated by Cargo
|
||||
# will have compiled files and executables
|
||||
debug
|
||||
target
|
||||
|
||||
# These are backup files generated by rustfmt
|
||||
**/*.rs.bk
|
||||
|
||||
# MSVC Windows builds of rustc generate these, which store debugging information
|
||||
*.pdb
|
||||
|
||||
# Generated by cargo mutants
|
||||
# Contains mutation testing data
|
||||
**/mutants.out*/
|
||||
|
||||
# rustc will dump stack traces when hitting an internal compiler error to PWD
|
||||
rustc-ice-*.txt
|
||||
|
||||
# Derived knowledge-graph artifacts generated by graphify
|
||||
graphify-out/
|
||||
|
||||
# Serena MCP local tooling state (per-machine project config and cache)
|
||||
.serena/
|
||||
|
||||
# tokensave MCP local tooling state (per-machine code-graph database and config)
|
||||
.tokensave/
|
||||
|
||||
# headroom MCP local tooling state (per-machine context-compression marker)
|
||||
.claude/.headroom_wrap_marker.json
|
||||
|
||||
# Machine-local files
|
||||
*.local.*
|
||||
tmp/
|
||||
|
||||
# RustRover
|
||||
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
||||
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
||||
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
||||
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
||||
#.idea/
|
||||
109
AGENTS.md
Normal file
109
AGENTS.md
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
# AGENTS.md
|
||||
|
||||
**CRITICAL:** This file contains the engineering context for AI coding agents.
|
||||
|
||||
## 🚨 Absolute Rules (Never Break These) / Do Not
|
||||
- **DO NOT** bypass the Lua scripting layer for gameplay logic.
|
||||
- **DO NOT** use `unwrap()` or `expect()` outside `main` or tests.
|
||||
- **DO NOT** use `println!`; use `tracing`.
|
||||
- **DO NOT** introduce non-deterministic logic into worldgen (no `thread_rng`, no `HashMap` iteration).
|
||||
- **DO NOT** build parallel registration systems or duplicate registries.
|
||||
|
||||
## 📖 What to Read
|
||||
- **Read `AGENTS.md` (this file)** first for every task.
|
||||
- **Read `DEVELOPMENT.md`** before making any architectural or cross-cutting changes.
|
||||
- **Read subsystem docs (`docs/` and rustdoc)** only for the specific crates you are modifying.
|
||||
|
||||
## ✅ Before Changing Code
|
||||
- Does this require updates to documentation or ADRs (`docs/adr/`)?
|
||||
- Which crate does this belong to? (Maintain strict boundaries).
|
||||
- Is this a new gameplay feature? If so, it must be exposed via the Lua API.
|
||||
|
||||
## 🛠️ Modification Priorities
|
||||
1. **Implement the feature in Lua** if possible, using existing APIs.
|
||||
2. **Extend the Lua API** if it lacks the required capability.
|
||||
3. **Modify Rust internals** only as a last resort to support the Lua API.
|
||||
Avoid bypassing the scripting layer entirely for gameplay features.
|
||||
|
||||
## 🏗️ Code Style & Edits
|
||||
- Prefer modifying existing systems over creating new abstractions.
|
||||
- Avoid duplicate registries, parallel APIs, unnecessary traits, and premature generic abstractions.
|
||||
- Keep changes local to the relevant module unless the architecture requires otherwise.
|
||||
|
||||
## ❓ When Unsure
|
||||
If an implementation conflicts with these rules, **prefer preserving the architecture over minimizing code changes.**
|
||||
|
||||
## 📦 Architectural Dependency Rules
|
||||
- `shared` stays lean and dependency-light (no `mlua`, no rendering).
|
||||
- `scripting` depends on `shared`, but `shared` does **not** depend on `scripting`.
|
||||
- `client` and `server` depend on `shared`, `scripting`, and `net`.
|
||||
- `client` depends on `renderer`, but `server` does not.
|
||||
- **Do not** put simulation logic in `client`.
|
||||
|
||||
## 🔍 File Location Hints
|
||||
- `/assets/scripts/`: Shipped base game Lua scripts.
|
||||
- `/mods/`: In-repo example mods/test fixtures.
|
||||
- `<user-data-dir>/mods/`: Player-installed mods (resolved at runtime).
|
||||
- `crates/client/`: Windowing, input, presentation.
|
||||
- `crates/server/`: Authoritative simulation.
|
||||
- `crates/shared/`: Core types, network protocols.
|
||||
- `crates/scripting/`: Lua API bindings.
|
||||
- `crates/renderer/`: Vulkan graphics.
|
||||
- `crates/net/`: QUIC networking.
|
||||
|
||||
## ⚙️ Basic Verification Commands
|
||||
- Check compilation: `cargo check -p <crate>`
|
||||
- Lint code: `cargo clippy --all-targets --all-features -- -D warnings`
|
||||
- Format Rust: `cargo fmt --all -- --check`
|
||||
- Format Lua: `stylua .` and `selene .`
|
||||
- Run tests: `cargo test -p <crate>`
|
||||
|
||||
## 🚀 Quick Start Task Checklist
|
||||
- [ ] Review "What to Read" and "Before Changing Code".
|
||||
- [ ] Check modification priorities (Lua vs Rust).
|
||||
- [ ] Make edits following code style guidelines.
|
||||
- [ ] Run basic verification commands.
|
||||
- [ ] Commit using Conventional Commits.
|
||||
|
||||
---
|
||||
|
||||
### Additional Subsystem Context
|
||||
|
||||
**Concurrency & State**
|
||||
- **Multithreaded:** Prefer message-passing and per-thread ownership over shared mutable state. Avoid large `Mutex` wrappers.
|
||||
- **Vulkan queues:** Not free-threaded.
|
||||
- **Lua VMs:** Not thread-safe. Treat each VM as owned by a single thread.
|
||||
|
||||
**Determinism**
|
||||
- **Worldgen:** Strictly seed-deterministic. Use fixed RNG algorithms (`wyrand`, `xoshiro`). Never use `rand::thread_rng()`. Do not rely on `HashMap` iteration order (use `BTreeMap` or `IndexMap`).
|
||||
- **Simulation:** Server-authoritative but not lockstep. Platform-specific math and floats are permitted outside of worldgen.
|
||||
|
||||
**Logging & Error Handling**
|
||||
- **Logging:** Use `tracing` and spans (`#[tracing::instrument]`). No `println!`.
|
||||
- **Libraries (`shared`, `renderer`, `scripting`):** Use `thiserror`.
|
||||
- **Binaries (`client`, `server`):** Use `anyhow`.
|
||||
|
||||
**Testing Expectations**
|
||||
- Test pure algorithmic logic, correctness traps (e.g. integer overflow, div_euclid), and determinism (worldgen).
|
||||
- I/O and GPU code are tested via integration/visual verification.
|
||||
- Ensure new tests run successfully and do not break existing ones.
|
||||
|
||||
**Linting**
|
||||
- The workspace uses strict lints (including banning `unwrap`, `expect`, `print`).
|
||||
- Prefer `#[expect(...)]` over `#[allow(...)]`. Suppress narrowly and justify non-obvious suppressions. Never suppress `correctness` lints.
|
||||
|
||||
**Documentation Style**
|
||||
- Formal, objective tone. No "we" or "you".
|
||||
- All public/internal struct fields need `///` docs.
|
||||
- Functions require `# Errors`, `# Panics`, and `# Safety` sections in that order.
|
||||
|
||||
**Branching Strategy**
|
||||
- **Development branch:** `dev`.
|
||||
- **Releases branch:** `main`.
|
||||
- **Large features:** Feature branch off `dev` (e.g., `feat/new-worldgen`).
|
||||
|
||||
**General Coding Standards**
|
||||
- **Content IDs:** Strict `"namespace:id"` format (e.g. `"core:stone"`). Interned to handles at runtime.
|
||||
- **Coordinate system:** +Y up, right-handed. 1 unit = 1 block (0.5m).
|
||||
- **Paths:** Linux/Windows only. Use `std::path::Path` and `directories` crate. No hard-coded `/home`.
|
||||
- **Commits:** Conventional Commits with crate scope (e.g., `feat(scripting): ...`).
|
||||
71
CLA.md
Normal file
71
CLA.md
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
# Synvael Contributor License Agreement
|
||||
|
||||
> ⚠️ **DRAFT — NOT YET LEGALLY REVIEWED.** This document has not been reviewed by legal counsel. It will be updated once a formal legal review is completed. The terms below represent the project's intent and will be finalized before contributions are accepted.
|
||||
|
||||
---
|
||||
|
||||
## Purpose
|
||||
|
||||
This Contributor License Agreement ("Agreement") establishes the terms under which contributions are made to the Synvael project, owned and maintained by Cryoforge Nexus ("the Project Maintainer"). "Cryoforge Nexus" refers to the legal entity registered under that name. Until formal registration is completed, this Agreement represents the project's stated intent and will become binding upon the entity's incorporation.
|
||||
|
||||
This Agreement is required because the Synvael project uses a dual-license model (AGPLv3 for source code, CC-BY-NC-SA 4.0 for assets) while also being commercially developed. Without this Agreement, contributed code and assets could not be included in a commercial release. The Agreement ensures that the Project Maintainer has the necessary rights to operate the project under both its open-source and commercial models, while contributors retain ownership of their work.
|
||||
|
||||
## Definitions
|
||||
|
||||
- **"Contribution"** means any original work of authorship — including source code, documentation, configuration, translations, textures, models, sounds, icons, shaders, and any other creative work — that is intentionally submitted by a Contributor to the Project for inclusion therein.
|
||||
|
||||
- **"Contributor"** (also "You") means the individual who submits a Contribution to the Project.
|
||||
|
||||
- **"Project"** means the Synvael software project and all associated repositories maintained by the Project Maintainer.
|
||||
|
||||
- **"Submit"** means any form of communication sent to the Project, including but not limited to pull requests, patches, commits, issues, and comments on any of the above, but excluding communications conspicuously marked or otherwise designated in writing as "Not a Contribution."
|
||||
|
||||
## 1. Grant of Copyright License
|
||||
|
||||
You hereby grant to the Project Maintainer a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare derivative works of, publicly display, publicly perform, sublicense, and distribute your Contributions and any derivative works thereof.
|
||||
|
||||
This license includes the right to relicense the Contribution under different license terms, including proprietary and commercial licenses, without further permission from or compensation to the Contributor.
|
||||
|
||||
## 2. Grant of Patent License
|
||||
|
||||
You hereby grant to the Project Maintainer a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Contribution, where such license applies only to those patent claims licensable by You that are necessarily infringed by Your Contribution alone or by combination of Your Contribution with the Project to which such Contribution was submitted.
|
||||
|
||||
If any entity institutes patent litigation against You or any other entity (including a cross-claim or counterclaim in a lawsuit) alleging that Your Contribution or the Project constitutes direct or contributory patent infringement, then any patent licenses granted to that entity under this Agreement for that Contribution or Project terminate as of the date such litigation is filed.
|
||||
|
||||
## 3. Ownership
|
||||
|
||||
You represent that You are the original author of the Contribution and that You are legally entitled to grant the above licenses. You represent that Your Contribution does not include any third-party code, assets, or other material unless such material is clearly identified and licensed under terms compatible with this Agreement.
|
||||
|
||||
You retain all right, title, and interest in and to Your Contributions. This Agreement does not transfer copyright ownership. Apart from the licenses granted herein, You reserve all rights in Your Contributions.
|
||||
|
||||
## 4. Representations
|
||||
|
||||
You represent that:
|
||||
|
||||
(a) Each Contribution is Your original creation.
|
||||
|
||||
(b) You have the legal authority to enter into this Agreement and grant the licenses described herein. If Your employer has rights to intellectual property that You create, You represent that You have received permission to make Contributions on behalf of that employer, that Your employer has waived such rights for Your Contributions to the Project, or that Your employer has executed a separate agreement with the Project Maintainer.
|
||||
|
||||
(c) Your Contribution does not knowingly violate any third party's intellectual property rights.
|
||||
|
||||
## 5. No Obligation
|
||||
|
||||
You understand that the decision to include Your Contribution in the Project is entirely at the discretion of the Project Maintainer. The Project Maintainer is under no obligation to use, merge, or distribute any Contribution.
|
||||
|
||||
## 6. No Compensation
|
||||
|
||||
You acknowledge that contributions to the Project are made voluntarily and without expectation of compensation, equity, revenue share, or any other financial consideration. Recognition is provided as described in [CONTRIBUTING.md](CONTRIBUTING.md).
|
||||
|
||||
## 7. Applicability
|
||||
|
||||
This Agreement applies to all Contributions submitted to the Project, whether those Contributions consist of source code (licensed under AGPLv3), assets (licensed under CC-BY-NC-SA 4.0), or any other form of creative work.
|
||||
|
||||
## 8. How to Sign
|
||||
|
||||
This Agreement is accepted by posting the following statement as a comment on a pull request to the Project:
|
||||
|
||||
> **I have read the CLA and I agree**
|
||||
|
||||
By posting this statement, You acknowledge that You have read and understood this Agreement and that You agree to be bound by its terms for all current and future Contributions to the Project.
|
||||
|
||||
Signatures are recorded automatically by the Project's CLA enforcement bot and stored in the repository at [`.github/cla-signatures.json`](.github/cla-signatures.json).
|
||||
5
CLAUDE.md
Normal file
5
CLAUDE.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
# CLAUDE.md
|
||||
|
||||
See `AGENTS.md` for project instructions. Do not edit this file.
|
||||
|
||||
@AGENTS.md
|
||||
132
CONTRIBUTING.md
Normal file
132
CONTRIBUTING.md
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
# Contributing to Synvael
|
||||
|
||||
Thanks for your interest in contributing to Synvael. This guide covers how contributions work, our expectations, and what you get out of it as a contributor.
|
||||
|
||||
## Philosophy
|
||||
|
||||
Synvael is **open-source and commercially developed**. The engine code uses the AGPLv3 license, and official game assets use CC-BY-NC-SA 4.0 (check [LICENSE.md](LICENSE.md) for the details). The project is built by Cryoforge Nexus, a commercial company that plans to monetize the finished game.
|
||||
|
||||
We welcome and credit outside contributions, but they don't include equity or revenue share. If that arrangement doesn't work for you, it's best not to contribute. We prefer being upfront about this rather than risking anyone feeling misled later on.
|
||||
|
||||
## Contributor Tiers
|
||||
|
||||
### Core team
|
||||
|
||||
The core team handles architectural and roadmap decisions. They are eligible for an ownership stake in Cryoforge Nexus based on the company's internal agreements.
|
||||
|
||||
Membership isn't open by default, but there is a way in. A contributor becomes **eligible for consideration** when they meet all these criteria:
|
||||
|
||||
1. **Sustained contribution:** At least six months of active, merged contributions. Consistency and quality are more important than sheer volume.
|
||||
2. **Architectural ownership:** The contributor has taken charge of at least one subsystem (like meshing, networking, worldgen, or a major Lua API surface) and has shown they can make good design decisions there, rather than just completing assigned tasks.
|
||||
3. **Community trust:** A solid track record of constructive code reviews, issue discussions, and teamwork. No history of conflict or ignoring project norms.
|
||||
|
||||
Once these criteria are met, an existing core member can **nominate** the contributor. Joining requires a **supermajority vote** (two-thirds or more) from the current core members, and the nominee is free to decline.
|
||||
|
||||
Meeting the criteria just makes someone eligible, not entitled to membership. The final call is a human judgement about long-term fit with the team.
|
||||
|
||||
### Outside contributors
|
||||
|
||||
This includes anyone who submits a pull request, asset, bug report, translation, or documentation change. Outside contributors get public credit, authorship on their work under our open-source licenses, code reviews, mentorship when helpful, and a voice in technical discussions. They do not get equity, revenue share, or any guarantee of future paid work.
|
||||
|
||||
## Contributor License Agreement (CLA)
|
||||
|
||||
Every contribution (both code and assets) requires signing a [Contributor License Agreement](CLA.md) before we can merge a pull request. The CLA does **not** transfer your copyright; you keep it. It simply grants Cryoforge Nexus a perpetual, irrevocable license to use, modify, sublicense, and relicense your contribution, including for commercial purposes.
|
||||
|
||||
This is necessary because the public licenses (AGPLv3 and CC-BY-NC-SA 4.0) would otherwise block our monetization plan. Without the CLA, contributed assets in particular would prevent the project from having a commercial release.
|
||||
|
||||
### How to sign
|
||||
|
||||
When you open a pull request, an automated check looks to see if all commit authors have signed the CLA. If not, it drops a comment with instructions. To sign, just leave a comment on the pull request saying:
|
||||
|
||||
```
|
||||
I have read the CLA and I agree
|
||||
```
|
||||
|
||||
The bot records your signature and updates the check status automatically. You only have to do this once. After that, all future pull requests from your GitHub account are accepted without needing to sign again.
|
||||
|
||||
## What is accepted
|
||||
|
||||
- **Code** in Rust (for engine crates) or Lua (for `assets/scripts/` and `mods/`), licensed under AGPLv3.
|
||||
- **Original assets** you created yourself: textures, models, sounds, icons, shaders. Licensed under CC-BY-NC-SA 4.0.
|
||||
- **Translations, documentation, bug reports, and design feedback.**
|
||||
|
||||
## What is not accepted
|
||||
|
||||
- **Assets derived from copyrighted third-party material** (like other games, films, or copyrighted art). Everything submitted must be your original work.
|
||||
- **AI-generated assets.** Textures, models, sounds, icons, and any other non-code assets must be originally made by humans. Check the [AI-Assisted Contributions](#ai-assisted-contributions) section below.
|
||||
- **Contributions without a signed CLA.** The bot enforces this strictly, so unsigned pull requests won't be merged.
|
||||
- **Native (Rust) mods submitted as pull requests.** Native mods that link against engine internals are considered derivative works under AGPLv3 and belong in their own separate repositories. Lua mods are completely welcome in `mods/`.
|
||||
|
||||
## Coding Standards & Architecture
|
||||
|
||||
For the complete technical engineering manual, including architecture boundaries, naming, documentation style, and lint rules, please refer to [DEVELOPMENT.md](DEVELOPMENT.md).
|
||||
|
||||
## Pull Request Process
|
||||
|
||||
1. **Branch from `dev`.** The `main` branch is kept strictly for stable releases. All active development happens on the **`dev`** branch. If you are working on a large feature, always create a new feature branch off `dev` (for example, `feat/new-worldgen`). Don't commit large, work-in-progress features directly to `dev`.
|
||||
2. **One concept per pull request.** Try to keep your changes focused. If a pull request touches multiple unrelated systems, please split it up.
|
||||
3. **Ensure CI passes.** Our pipeline runs `cargo fmt`, `cargo clippy`, `selene`, and `stylua`. Pull requests with lint failures won't be reviewed. Always make sure your code passes these tools locally before pushing.
|
||||
4. **Sign the CLA.** The CLA bot must show a passing status before we start reviewing.
|
||||
5. **Describe the change.** Explain what your pull request does, why it's needed, and any design decisions you made. If it relates to any open issues, link them.
|
||||
6. **Respond to review feedback.** Maintainers might request changes. Please address them or discuss alternative approaches with us.
|
||||
|
||||
## Contributing workflow
|
||||
|
||||
Before you commit a change, verify it against the actual repo state instead of assuming it's correct. Read the files, inspect `git diff`, and run `cargo check`, `cargo clippy`, or `cargo test` as needed. Then just follow this loop for each change:
|
||||
|
||||
1. **Verify** that the change is actually present and correct in your working tree.
|
||||
2. **Run the linter and formatter** to ensure no regressions or style issues sneaked in: `cargo clippy --all-targets --all-features -- -D warnings`, `cargo fmt --all -- --check`, `selene .`, and `stylua .`.
|
||||
3. **Ensure useful comments are present** before committing: add function doc comments (`///`) and inline comments above non-obvious logic, sticking to the project's documentation style.
|
||||
4. **Create a focused git commit** using the commit conventions listed below.
|
||||
|
||||
Keep commits scoped to a single concept. Don't bundle multiple unrelated changes into one commit, and try not to leave a verified change uncommitted before moving on to the next thing.
|
||||
|
||||
## Commit conventions
|
||||
|
||||
We use [**Conventional Commits**](https://www.conventionalcommits.org/) with a **mandatory crate-name scope**.
|
||||
|
||||
Format:
|
||||
|
||||
```
|
||||
<type>(<crate>): <imperative subject>
|
||||
|
||||
[optional body]
|
||||
|
||||
[optional footer(s)]
|
||||
```
|
||||
|
||||
- **Type** (required, exactly one): `feat` (new feature), `fix` (bug fix), `refactor` (no behaviour change), `perf`, `docs`, `test`, `chore` (build/tooling/deps), `build`, `ci`. If there are breaking changes, append a `!` before the colon: `feat(scripting)!: ...`.
|
||||
- **Scope** (required): the crate the change primarily affects, choosing from `client`, `server`, `renderer`, `shared`, or `scripting`. If the change spans the whole workspace (like a Cargo config change or `.gitattributes`), use `workspace`. For changes isolated to non-Rust assets, use `assets`. Please avoid omitting the scope and don't make up new scopes per commit.
|
||||
- **Subject:** imperative mood (use "add", not "added" or "adds"), lowercase, no trailing period, and keep it under 72 characters.
|
||||
- **Body:** keep commit messages short and sweet. Usually just a subject is fine. The main exception is `fix(...)` commits for non-trivial bugs, where a body explaining the root cause and why the fix actually works is super helpful. There's no need to pad routine commits with bodies.
|
||||
|
||||
Examples:
|
||||
|
||||
```
|
||||
feat(scripting): expose blocks.register to lua
|
||||
fix(renderer): clamp swapchain extent to surface caps
|
||||
refactor(shared): split network message types into submodule
|
||||
chore(workspace): bump ash to 0.39
|
||||
docs(assets): document texture-pack overlay layout
|
||||
feat(server)!: change tick rate from 20 to 30 Hz
|
||||
```
|
||||
|
||||
If a single commit touches multiple crates and honestly can't be reasonably split, that's usually a sign that it should be split anyway. Only fall back to the `workspace` scope when the change is fundamentally workspace-wide.
|
||||
|
||||
## Recognition
|
||||
|
||||
Every merged contribution gets an entry in `CREDITS.md`. Major or sustained contributions are highlighted on the project website. Standout contributors might be offered paid bounties for specific scoped work once the project starts generating revenue. This is a transactional setup, not equity or an ongoing revenue share.
|
||||
|
||||
This is the hard limit of what outside contribution earns. If your goal is co-ownership of a game studio, this project probably isn't the right fit.
|
||||
|
||||
## AI-Assisted Contributions
|
||||
|
||||
You are welcome to use AI tools like code completion, generation, or refactoring assistants when writing code. Just keep these rules in mind:
|
||||
|
||||
- **AI-assisted code is accepted, with conditions.** You must genuinely review every line of the submitted code and be able to explain what it does and why. If you can't answer questions about your own code during review, the contribution will be rejected. You are the responsible author, not the AI tool.
|
||||
- **AI-generated assets are not accepted.** Textures, models, sounds, icons, and other non-code assets must be original human-authored work. This rule applies regardless of the tool used or where its training data came from.
|
||||
- **Disclosure is required.** If you used AI tools in a meaningful way to create a code contribution, state this in the pull request description. A quick note like "AI-assisted: used Copilot for boilerplate generation" is completely fine.
|
||||
|
||||
## Questions?
|
||||
|
||||
Feel free to open an issue or start a discussion on the repository. Maintainers are always happy to help with setup, answer questions about the architecture, or chat about proposed changes before you start working on them.
|
||||
3731
Cargo.lock
generated
Normal file
3731
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
45
Cargo.toml
Normal file
45
Cargo.toml
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
[workspace]
|
||||
resolver = "3"
|
||||
members = ["crates/*"]
|
||||
|
||||
[workspace.package]
|
||||
license = "AGPL-3.0-only"
|
||||
license-file = "LICENSE.md"
|
||||
authors = ["Cryoforge Nexus"]
|
||||
edition = "2024"
|
||||
version = "0.1.0"
|
||||
|
||||
# Dependencies used by more than one crate are pinned once here so a version can never drift between crates.
|
||||
[workspace.dependencies]
|
||||
anyhow = "1.0.103"
|
||||
glam = { version = "0.33", features = ["serde"] }
|
||||
bytemuck = { version = "1.21", features = ["derive"] }
|
||||
tracing = "0.1.44"
|
||||
tracing-subscriber = { version = "0.3.23", features = ["env-filter"] }
|
||||
serde_json = "1.0.149"
|
||||
sysinfo = { version = "0.33", default-features = false, features = ["system"] }
|
||||
ash-window = "0.13.0"
|
||||
raw-window-handle = "0.6.2"
|
||||
thiserror = "2.0.18"
|
||||
serde = { version = "1.0.228", features = ["derive"] }
|
||||
postcard = { version = "1.1.3", features = ["use-std"] }
|
||||
|
||||
[workspace.lints.rust]
|
||||
unsafe_code = "warn"
|
||||
missing_docs = "warn"
|
||||
|
||||
[workspace.lints.clippy]
|
||||
unwrap_used = "warn"
|
||||
expect_used = "warn"
|
||||
print_stdout = "warn"
|
||||
print_stderr = "warn"
|
||||
|
||||
pedantic = { level = "warn", priority = -1 }
|
||||
clone_on_ref_ptr = "warn"
|
||||
|
||||
todo = "warn"
|
||||
unimplemented = "warn"
|
||||
|
||||
# Pedantic exceptions (too noisy)
|
||||
module_name_repetitions = "allow"
|
||||
must_use_candidate = "allow"
|
||||
195
DEVELOPMENT.md
Normal file
195
DEVELOPMENT.md
Normal file
|
|
@ -0,0 +1,195 @@
|
|||
# Development Guidelines
|
||||
|
||||
This file is the single source of truth for architecture, conventions, and workflow for human contributors working on Synvael. Tool-specific entry points (like `CLAUDE.md`) and AI instructions (like `AGENTS.md`) import or summarize this file rather than duplicating it.
|
||||
|
||||
## Documentation map
|
||||
|
||||
Our documentation is layered by altitude. Try to keep content at the layer it belongs to so no single file ends up hoarding everything.
|
||||
|
||||
- **This file (`DEVELOPMENT.md`)**: covers cross-cutting engineering conventions and architecture invariants. These are the rules that apply no matter what feature you're touching. There is a finite set of these, so this file should grow pretty slowly. Subsystem-specific specs do not belong here.
|
||||
- **[`docs/`](docs/) and Rust module docs (`//!`, `///`)**: per-subsystem technical implementation docs explaining how an individual system (meshing, networking, worldgen, etc.) is actually built. We prefer module docs right next to the code. If a design spans multiple files, promote it to a `docs/<subsystem>.md` note.
|
||||
- **[`docs/adr/`](docs/adr/)**: Architecture Decision Records capturing the "why" behind significant, hard-to-reverse choices, with one append-only file per decision. Check out [`docs/README.md`](docs/README.md) for the full structure and [`docs/adr/0001-record-architecture-decisions.md`](docs/adr/0001-record-architecture-decisions.md) for the practice itself.
|
||||
|
||||
The canonical game design specification covering intent, world rules, and gameplay behaviour is maintained separately and isn't part of this repository. This repo only documents how that design gets implemented.
|
||||
|
||||
## Workspace layout
|
||||
|
||||
We use a Cargo workspace (resolver = "3", edition 2024) containing six crates under `crates/`:
|
||||
|
||||
- `client`: binary. This is the windowed application using `winit` 0.30 (`ApplicationHandler` pattern, `ControlFlow::Poll`). It also pulls in `image`. This is the player-facing app titled "Synvael". It handles input, windowing, and drives the renderer.
|
||||
- `server`: binary. The authoritative game simulation covering the voxel world, combat, and players. It is used for dedicated multiplayer hosts and also acts as the simulation backend for single-player.
|
||||
- `renderer`: library. Voxel and scene rendering on Vulkan using `ash`. It is deliberately decoupled from windowing so the `client` can drive it.
|
||||
- `shared`: library. Types and protocols shared between `client` and `server` like world and voxel data, network messages, and combat primitives. This crate stays lean and dependency-light. It has no `mlua`, no rendering, and no engine internals.
|
||||
- `scripting`: library. The Lua modding API and bindings. This crate owns the `mlua` dependency, `UserData` wrappers around `shared` types, API table registration, and the mod loader. Both `client` and `server` depend on it.
|
||||
- `net`: library. QUIC transport, connection lifecycle, and wire framing for the client-server protocol. It owns the async runtime (`tokio`) and the `quinn` and `rustls` dependencies. Both `client` and `server` depend on it. See [ADR-0010](docs/adr/0010-net-crate-async-runtime.md) for more details.
|
||||
|
||||
When adding code, please keep these boundaries tight. Protocol and data types plus game-rule primitives go in `shared`. Lua API surfaces and `mlua` integration live in `scripting`. GPU and drawing code goes in `renderer`. Transport and connection code belongs in `net` (but protocol message types stay in `shared`). Only input, windowing, and presentation glue should live in `client`. Try to avoid growing `client` with simulation logic, since it needs to work identically whether it's talking to a local or remote `server`.
|
||||
|
||||
## Modding API (Lua): dogfooded
|
||||
|
||||
The game exposes a Lua modding API, and **the base game itself is built directly on top of that same API** rather than treating it as a separate add-on layer. Built-in content like blocks, items, entities, and recipes are defined through the modding API so mod authors can read the shipped code as a reference for what's possible and how to do it.
|
||||
|
||||
This has some strict implications when adding new features:
|
||||
|
||||
- Any new gameplay primitive (a new block type, item, entity, ability, etc.) needs to be accessible through the Lua API, not just as a Rust-only path. If you add a Rust-side concept without an API surface, you've broken our dogfooding rule.
|
||||
- Prefer extending the API and then *using* it from the engine over adding a parallel Rust-only entry point.
|
||||
- Keep the API stable and easy to discover, since mod authors will be reading it. Avoid leaking engine internals through it.
|
||||
- The API and its bindings live strictly in the **`scripting`** crate. It owns the `mlua` dependency, the API table registration, and the mod loader. Both `client` and `server` depend on it. `shared` does **not**, since it needs to stay as a lean protocol layer.
|
||||
- Authoritative APIs like world mutation and combat resolution are defined in `scripting` but gated so the client-side Lua VM cannot invoke them. We use one API surface across two execution contexts: the client VM is for read-only UI and effects, while the server VM is authoritative.
|
||||
- Use wrapper newtypes inside `scripting` rather than `impl UserData for SharedType` in `shared`. This prevents coupling the protocol crate to `mlua`.
|
||||
|
||||
The decision to build the base game on top of the modding API and the client/server VM gating that follows are recorded in [ADR-0006](docs/adr/0006-base-game-on-modding-api.md).
|
||||
|
||||
## Assets
|
||||
|
||||
All game assets live under `/assets` at the repo root, organized into subfolders by kind: `icons/`, `models/`, `shaders/`, `sounds/`, `textures/`, and `scripts/`. New assets must go in the matching subfolder. Don't drop loose files directly into `/assets`, and don't scatter assets inside crate directories.
|
||||
|
||||
Assets are published openly under CC-BY-NC-SA 4.0 (check `LICENSE.md`). Binary assets like textures, models, sounds, and compiled shaders are tracked using **Git LFS**. Keep `.gitattributes` up to date when adding a new binary file type. Lua scripts and JSON data are plain text files and live in standard Git.
|
||||
|
||||
## Script locations
|
||||
|
||||
We use three distinct locations for scripts. Please do not mix them:
|
||||
|
||||
- **`/assets/scripts/`**: the base game's own Lua, shipped with the binary. This is the dogfooded "first-party mod" the engine loads through the same API mod authors use. We mirror the structure modders will use (like `scripts/blocks/`, `scripts/items/`, `scripts/entities/`) so it serves as a working reference.
|
||||
- **`/mods/`** (top-level): in-repo example mods or test fixtures. We keep these out of `/assets/` because they aren't engine-shipped content, and out of `crates/` because they aren't Rust source code.
|
||||
- **`<user-data-dir>/mods/`**: player-installed mods, loaded only at runtime. This path is resolved via the `directories` or `dirs` crate (on Linux, it's `~/.local/share/synvael/mods/`, with platform equivalents elsewhere). Never read from a hard-coded path.
|
||||
|
||||
## Data packs & resource packs
|
||||
|
||||
These are two distinct, orthogonal systems. Keep them separate, and don't merge them into one "pack" concept. **Resource packs** are client-side asset overlays covering textures, sounds, models, fonts, and language files. They contain no logic. **Data packs** are declarative content definitions (using JSON, TOML, or RON) covering blocks, items, recipes, loot tables, biomes, and tags.
|
||||
|
||||
Our strict rule here: **do not build a parallel registration system.** The data-pack loader reads declarative files and calls the exact same Lua API that the engine and Lua mods use, ensuring one single source of truth (e.g. `data/blocks/stone.json` is read by the loader, which calls `blocks.register{ ... }`). Each schema is a stable contract that we version deliberately. This decision is recorded in [ADR-0007](docs/adr/0007-declarative-content-via-modding-api.md).
|
||||
|
||||
Full subsystem details regarding load order, repo and user-data layouts, and resolution semantics can be found in [`docs/packs.md`](docs/packs.md).
|
||||
|
||||
## Concurrency model
|
||||
|
||||
The game is **multithreaded by design**. A single-threaded approach simply wouldn't meet our performance budget for running voxel meshing, worldgen, rendering, networking, and simulation all at once. Assume multiple threads when writing code and design data ownership accordingly:
|
||||
|
||||
- Prefer message-passing using channels (`crossbeam-channel`, `flume`, or `std::sync::mpsc`) and per-thread ownership rather than shared mutable state.
|
||||
- When sharing is completely unavoidable, use the right primitive for your access pattern. Use `Arc<Mutex<_>>` for low-contention shared state, `Arc<RwLock<_>>` for read-heavy state, atomics like `AtomicU32` or `AtomicBool` for counters and flags, and lock-free structures from `crossbeam` or `dashmap` for hot paths. Try to avoid wrapping large hot data in a single `Mutex` "just in case", as this can easily accidentally serialize the entire engine.
|
||||
- Worldgen and chunk meshing are massive parallelism wins. We expect a thread pool like `rayon` or a hand-rolled one to feed meshing and generation jobs.
|
||||
- Vulkan command-buffer recording can also be parallelized, but Vulkan **queues** are not free-threaded. Only one thread can submit to a given queue at a time, so plan ownership of `vk::Queue` accordingly.
|
||||
- The Lua VMs (one per execution context for client and server) are **not** thread-safe in `mlua`'s default configuration. Treat each VM as owned by a single thread, and dispatch work to and from it using channels.
|
||||
|
||||
## Logging & error handling
|
||||
|
||||
- **Logging:** We use [`tracing`](https://docs.rs/tracing/) with `tracing-subscriber` as the output backend. Use `info!`, `warn!`, `error!`, `debug!`, and `trace!` macros at appropriate levels. It's crucial to use **spans** (`#[tracing::instrument]`, `info_span!`) to scope work, as this is how we keep multithreaded log output readable. Avoid using `println!` or `eprintln!` for diagnostics. If it's worth printing, it's worth a proper `tracing` event.
|
||||
- **Errors in libraries** (`shared`, `renderer`, `scripting`): Use typed error enums via [`thiserror`](https://docs.rs/thiserror/) using `#[derive(Error)]`. Each variant should be a distinct, matchable failure mode. Do not expose `anyhow::Error` from a library API.
|
||||
- **Errors in binaries** (`client`, `server`): Use [`anyhow`](https://docs.rs/anyhow/) at the top level, leaning on `.context("...")` to provide human-readable layers. Library errors compose smoothly into `anyhow::Error` using the `?` operator.
|
||||
- **Never use `.unwrap()` or `.expect()` outside of `main`, setup logic, or tests.** The only exception is when an invariant is genuinely impossible to violate. On hot paths, propagate errors with `?` and let the caller decide what to do.
|
||||
|
||||
## Testing policy
|
||||
|
||||
We prioritize tests based on risk, not raw coverage percentages. We direct our testing effort toward areas where code that compiles and appears correct isn't guaranteed to actually be correct. You must write accompanying unit tests for these categories in the same change that introduces or modifies the logic:
|
||||
|
||||
- **Pure algorithmic logic.** Things with values in, values out, no I/O, no GPU, and no windowing. This includes coordinate and index math, packing and unpacking, meshing math, and similar self-contained computations. These are cheap to test and their edges are notoriously easy to get subtly wrong.
|
||||
- **Correctness traps.** Behaviors where a totally plausible implementation is silently wrong on an edge case. Examples include sign handling, off-by-one errors, integer overflow or truncation, and bit-packing boundaries. As a classic example, world-to-chunk conversion needs to floor via `div_euclid` rather than truncating via `/`. A test on negative inputs locks in that contract and prevents someone from accidentally regressing to `/`.
|
||||
- **Load-bearing invariants (especially determinism).** As noted in our determinism stance below, worldgen is seed-deterministic and bit-for-bit reproducible. That contract can't be verified just by looking at the code, so it is strictly guarded by tests (for example, generating a chunk twice from one seed and asserting they are equal). We guard determinism aggressively.
|
||||
|
||||
Subsystems that are bound by I/O or hardware (like the `renderer` and Vulkan GPU paths, `client` windowing and input, and top-level binary wiring) are validated through integration tests and manual visual verification rather than strict unit tests. Their behavior relies on a live device, window, or process rather than pure logic. While the mechanism differs, the expectation that they are properly verified does not.
|
||||
|
||||
Unit tests live right next to the code as `#[cfg(test)] mod tests` and are run using `cargo test -p <crate>`.
|
||||
|
||||
## Lint suppressions
|
||||
|
||||
The workspace opts into a strict set of lints. This includes Clippy's `pedantic` group along with restriction lints that ban `unwrap`, `expect`, and `print` outside permitted contexts (you can check `[workspace.lints]` in the root `Cargo.toml`). Suppressions are expected at specific sites and are governed by these rules:
|
||||
|
||||
- **Always prefer `#[expect(...)]` over `#[allow(...)]`** for a localized suppression. An `#[expect]` turns into a warning (`unfulfilled_lint_expectations`) if the lint it targets no longer fires, meaning obsolete suppressions surface automatically and can be cleaned up instead of lingering silently. `#[allow]` never self-reports and just accumulates as dead noise.
|
||||
- **Suppress narrowly.** Name the exact lint or lints, and attach the attribute to the absolute smallest scope that covers the site (like a statement, expression, or item). Never use a broad crate-level `#![allow]`. The only exception is a deliberate crate-wide policy, such as `#![allow(unsafe_code)]` in the `renderer`, where the suppression represents an architectural intent rather than a local waiver.
|
||||
- **Justify non-obvious suppressions.** If the reason a lint is safe to suppress isn't totally obvious from the surrounding code, leave a brief comment above the attribute explaining why (for instance, noting that a specific cast is mathematically provably in range).
|
||||
- **Never suppress `correctness`-tier lints.** These indicate real defects. Fix the code instead.
|
||||
|
||||
## Documentation style
|
||||
|
||||
- **Objective Tone:** All comments (both doc comments `///` and inline `//`) must be written in a formal, objective, and neutral tone.
|
||||
- **No Personal Pronouns:** Avoid first-person ("we", "our", "us") and second-person ("you", "your") pronouns.
|
||||
- **Voice:** Try to use the passive voice or neutral descriptive language. Instead of "We initialize the buffer," try "The buffer is initialized." Instead of "Your vertex shader needs this," write "The vertex shader requires this."
|
||||
- **Focus:** Describe the code's behavior, the system's state, or technical invariants.
|
||||
- **Struct Documentation:** Every single field in a public or internal struct needs a doc comment (`///`) explaining what it's for and any invariants it holds.
|
||||
- **Function documentation sections:** Function doc comments should follow the standard sections from the [Rust API Guidelines](https://rust-lang.github.io/api-guidelines/documentation.html). They should appear in this fixed order after the summary and description: `# Errors`, then `# Panics`, then `# Safety`. These sections apply to **all** functions, whether public or private. Clippy only enforces public ones, but we expect the same standard on private helpers by hand.
|
||||
- **`# Errors`** is mandatory on every function returning a `Result`. It needs to state the exact conditions under which each error variant is returned. `fn main` is exempt from this.
|
||||
- **`# Panics`** is mandatory on any function that can panic. This includes `expect`, `unwrap`, `panic!`, `assert!`, array indexing, or arithmetic that can trip. State the condition that triggers the panic.
|
||||
- **`# Safety`** is mandatory on every `unsafe fn`, stating the exact invariants the caller must uphold.
|
||||
- Test functions (`#[test]`, and helpers inside `#[cfg(test)]`) are fully exempt from all three sections since they aren't part of the documented surface.
|
||||
- We use `missing_errors_doc`, `missing_panics_doc`, and `missing_safety_doc` as warnings in our workspace lint set, so missing a section on a public item will fail CI.
|
||||
- **Stability:** Treat the documentation as a technical specification for the engine.
|
||||
- **Line breaks:** Do not insert line returns inside a comment unless it's genuinely necessary. If a comment fits on a single line, leave it on a single line. Don't artificially wrap text at 80 characters just for aesthetics. Only break across lines when the comment is actually long (like multi-sentence prose or enumerated invariants) or when a hard break carries meaning (like separating an intro line from a bulleted list).
|
||||
|
||||
## Target platforms
|
||||
|
||||
**Linux and Windows only.** We do not support macOS, mobile, consoles, or web/WASM.
|
||||
|
||||
- Both platforms feature native Vulkan support via vendor ICDs (NVIDIA, AMD, Intel). There is no translation layer like MoltenVK, meaning we can adopt modern Vulkan extensions freely without checking a portability matrix.
|
||||
- **File paths:** Always use `std::path::Path` or `PathBuf` along with the `directories` (or `dirs`) crate for looking up user data. Never hard-code paths like `/home/...` or `~`. Linux properly follows XDG standards (`$XDG_DATA_HOME`, etc.), while Windows correctly uses `%APPDATA%`.
|
||||
- **Line endings:** The repository is strictly LF-only. Make sure to set `core.autocrlf = false` and rely on our `.gitattributes` setting `* text eol=lf` to keep diffs completely clean across both operating systems.
|
||||
- **Filename casing:** Never create two files that differ only in casing. Linux is case-sensitive and Windows isn't, so mismatches create incredibly confusing "works on my machine" bugs.
|
||||
|
||||
## Determinism stance
|
||||
|
||||
- **Worldgen is seed-deterministic.** Given the exact same seed, worldgen must produce bit-for-bit the same world on any platform, at any time. This strongly constrains our worldgen code: you must use a fixed RNG algorithm like `wyrand` or `xoshiro`. **Never** use `rand::thread_rng()` or anything seeded directly from the OS. Do not depend on `HashMap` iteration order, as Rust's default hasher is randomized. Use `BTreeMap`, `IndexMap`, or explicitly sort your data when iteration order feeds into RNG draws or content placement. For more detail, check [ADR-0003](docs/adr/0003-seed-deterministic-worldgen.md).
|
||||
- **Simulation is server-authoritative.** The server runs the absolute truth. Clients send their inputs and receive state snapshots back, predicting locally for responsiveness and reconciling whenever they disagree with the server. Combat, physics, mob AI, and item drops are computed exactly once, on the server.
|
||||
- **Full simulation determinism (lockstep, rollback, replay-from-inputs) is a non-goal.** Because of this, floats, hash-map iteration, and platform-specific math are all totally fair game *outside of worldgen*. We don't want to pay the massive performance cost of cross-platform float reproducibility for a feature we aren't even building. See [ADR-0004](docs/adr/0004-server-authoritative-simulation.md).
|
||||
|
||||
## Content IDs & namespacing
|
||||
|
||||
All registered content (like blocks, items, recipes, biomes, and entities) is identified using a **namespaced string** in the exact format `"namespace:id"`. The full rationale for this is in [ADR-0005](docs/adr/0005-namespaced-content-ids.md).
|
||||
|
||||
- **Engine's reserved namespace:** `core:`. All first-party content registered directly by the base game uses this namespace (e.g. `"core:stone"`, `"core:iron_sword"`). Mods pick their own short namespace (e.g. `"mymod:weird_dirt"`).
|
||||
- **Strict form required.** A bare ID with no `:` is considered an **error at registration and parse time**. It will not be silently coerced to `core:`. This same rule applies absolutely everywhere: engine scripts, data packs, Lua mods, recipe references, and save files. There are no exceptions. The symmetry is entirely the point.
|
||||
- **Charset:** The namespace and id must each match `[a-z0-9_-]+`, separated by exactly one `:`. Stick to lowercase ASCII only. No uppercase letters, no Unicode, no spaces, no dots, and no slashes. This keeps IDs easy to grep, completely filesystem-safe, and unambiguous in logs and save data.
|
||||
- **Runtime representation:** We intern each ID string into a small integer handle (like `BlockId(u32)`) when it gets registered. Hot paths should always compare handles, never strings. We keep the original string around purely for display, saving and loading, and the Lua API surface.
|
||||
|
||||
> *Project name note:* The project is named **Synvael** ("Catalyst" was our old working codename). The engine namespace is deliberately kept as `core:` rather than the project name, ensuring it stays stable even if branding changes.
|
||||
|
||||
## Coordinate system & units
|
||||
|
||||
- **Up axis:** **+Y**.
|
||||
- **Handedness:** **right-handed** (this is the default math convention where +X is right, +Y is up, and +Z points toward the viewer or out of the screen).
|
||||
- **World unit:** **1 unit = 1 block.** Blocks are exactly 0.5 meters in physical scale, but inside the engine, everything is counted in *blocks*, not meters. A player is therefore exactly 3 units tall and 2 units wide in world coordinates.
|
||||
|
||||
We've collected implementation gotchas that pop up because neighboring tools use different conventions (like Vulkan clip space, Blender import, or glTF) in [`docs/rendering.md`](docs/rendering.md). Note that these are not convention changes for the engine, just mismatches that we handle in one agreed-upon place.
|
||||
|
||||
## Development Setup
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- [Rust](https://www.rust-lang.org/tools/install) (stable toolchain, edition 2024)
|
||||
- [Git LFS](https://git-lfs.com/) (binary assets are tracked via LFS)
|
||||
- A Vulkan-capable GPU with up-to-date drivers (Linux or Windows)
|
||||
|
||||
### Building
|
||||
|
||||
```bash
|
||||
git clone https://github.com/Cryoforge-Nexus/Synvael.git
|
||||
cd Synvael
|
||||
git lfs pull
|
||||
cargo build
|
||||
```
|
||||
|
||||
### Running
|
||||
|
||||
```bash
|
||||
cargo run -p client # windowed client
|
||||
cargo run -p server # dedicated server
|
||||
```
|
||||
|
||||
### Testing
|
||||
|
||||
```bash
|
||||
cargo test # all tests
|
||||
cargo test -p shared # tests for a single crate
|
||||
```
|
||||
|
||||
### Linting
|
||||
|
||||
The CI pipeline enforces strict linting. Run these locally before pushing your code:
|
||||
|
||||
```bash
|
||||
cargo fmt --all -- --check
|
||||
cargo clippy --all-targets --all-features -- -D warnings
|
||||
selene .
|
||||
stylua .
|
||||
```
|
||||
|
||||
Lua linting requires [Selene](https://kampfkarren.github.io/selene/) and [StyLua](https://github.com/JohnnyMorganz/StyLua). You can install them by running `cargo install selene` and `cargo install stylua`, or by using the pre-built binaries from their GitHub release pages.
|
||||
79
LICENSE.md
Normal file
79
LICENSE.md
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
# Synvael License Agreement
|
||||
|
||||
This project is licensed under a multi-part licensing model. By accessing, using, or distributing any part of this project, you agree to be bound by the terms below.
|
||||
|
||||
---
|
||||
|
||||
## Part 1: Source Code License (GNU AGPLv3)
|
||||
|
||||
**Applicability:** All `.rs` files located in `crates/`, all `.lua` files in `assets/scripts/` and `mods/`, the workspace `Cargo.toml`, every per-crate `Cargo.toml`, and all build-configuration files (`rustfmt.toml`, `selene.toml`, `stylua.toml`, etc.).
|
||||
|
||||
Copyright (c) 2026 Cryoforge Nexus
|
||||
|
||||
This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License version 3 as published by the Free Software Foundation.
|
||||
|
||||
This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the [GNU Affero General Public License](https://www.gnu.org/licenses/agpl-3.0.txt) for more details.
|
||||
|
||||
**Note on AGPLv3:** Beyond standard GPLv3 obligations, AGPLv3 also requires that anyone who makes a *modified version* of this program available to users over a network must make the corresponding source code of that modified version available to those users. Running an *unmodified* version of the program as a server does **not** trigger this obligation.
|
||||
|
||||
---
|
||||
|
||||
## Part 2: Game Assets License (CC-BY-NC-SA 4.0)
|
||||
|
||||
**Applicability:** All files located in the `assets/` directory (including but not limited to textures, models, sounds, icons, and shaders), **except** `assets/scripts/` which is covered by Part 1.
|
||||
|
||||
The game assets are licensed under the **Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International License**.
|
||||
|
||||
**Under this license, you are free to:**
|
||||
- **Share**: copy and redistribute the material in any medium or format.
|
||||
- **Adapt**: remix, transform, and build upon the material.
|
||||
|
||||
**Under the following terms:**
|
||||
- **Attribution (BY):** You must give appropriate credit, provide a link to the license, and indicate if changes were made.
|
||||
- **Non-Commercial (NC):** You may not use the material for commercial purposes.
|
||||
- **Share-Alike (SA):** If you remix, transform, or build upon the material, you must distribute your contributions under the same license as the original.
|
||||
|
||||
To view a copy of this license, visit [http://creativecommons.org/licenses/by-nc-sa/4.0/](http://creativecommons.org/licenses/by-nc-sa/4.0/).
|
||||
|
||||
This license explicitly permits the creation and distribution of **resource packs** that modify or replace official assets, provided they remain non-commercial and are themselves released under CC-BY-NC-SA 4.0.
|
||||
|
||||
**Open distribution of assets:** Game assets are published openly in this repository (typically tracked via Git LFS). This is intentional. Modders, resource pack authors, and self-builders are welcome to clone, reference, and remix them. The non-commercial and share-alike obligations remain in force regardless of how the assets are obtained. Cloning the repository does not grant any commercial-use rights.
|
||||
|
||||
---
|
||||
|
||||
## Part 3: Modding Policy
|
||||
|
||||
**Applicability:** Mods for Synvael, content written by third parties that runs through the modding API or replaces game assets.
|
||||
|
||||
Cryoforge Nexus grants a limited, non-exclusive, non-transferable license to use the Game Assets (Part 2) and the modding API (Part 1) specifically for the creation, distribution, and play of mods for Synvael, provided that:
|
||||
|
||||
1. **Non-Commerciality:** The mod itself must be free to download and play. You may not sell the mod or charge for access to it. Donation-based support of mod authors (Patreon, Ko-fi, etc.) is permitted as long as the mod itself remains freely available.
|
||||
2. **Platform Integration:** Mods are encouraged to be shared through the official Synvael modding platform.
|
||||
3. **No Standalone Usage:** You may not use the Game Assets to create a standalone game or software product unrelated to Synvael.
|
||||
4. **Credit:** You must credit Synvael as the source of the assets.
|
||||
5. **Mod licensing, Lua vs native code:**
|
||||
- **Lua mods** running on the modding-API VM are *not* considered derivative works of the engine. Lua mod authors may license their own code under any license they choose.
|
||||
- **Native (Rust) mods** that link against engine internals are derivative works of the engine and are subject to the AGPLv3 terms of Part 1. If distributed, their source must be made available under AGPLv3.
|
||||
6. **Mod assets:** Original assets authored by the mod creator may be released under any license. Assets *derived* from official Synvael assets are governed by Part 2 (CC-BY-NC-SA 4.0) and must be shared under the same terms.
|
||||
|
||||
This policy is intended to foster a vibrant modding community while protecting the core identity of the game.
|
||||
|
||||
---
|
||||
|
||||
## Part 4: Server Hosting Policy
|
||||
|
||||
**Applicability:** Anyone hosting a Synvael server, official or community-run.
|
||||
|
||||
Non-commercial server hosting is permitted without a separate commercial license, including:
|
||||
|
||||
- Hosting a public server for free.
|
||||
- Hosting a public server supported by donations or Patreon, where funds are used only to cover hosting and operational costs.
|
||||
- Hosting a private or whitelisted server for friends, communities, or organizations.
|
||||
|
||||
The following are **not permitted** without a separate commercial license from Cryoforge Nexus:
|
||||
|
||||
- Selling in-game advantages, premium ranks, cosmetic items, or any pay-to-win mechanics on a server.
|
||||
- Charging players for access to the server itself (paywalled play).
|
||||
- Operating a server as part of a commercial gaming-platform business model.
|
||||
|
||||
Note that under AGPLv3 (Part 1), running a *modified* version of the engine as a server requires making the modified source code available to all users connecting to that server, regardless of whether the hosting is commercial or non-commercial.
|
||||
|
|
@ -1 +0,0 @@
|
|||
# Project Catalyst\n\nStable release branch for the Project Catalyst voxel engine.
|
||||
7
assets/data/worldgen/default.json
Normal file
7
assets/data/worldgen/default.json
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"base_height": 16,
|
||||
"noise_scale": 0.02,
|
||||
"surface_block": 2,
|
||||
"subsurface_block": 1,
|
||||
"stone_block": 3
|
||||
}
|
||||
3
assets/scripts/core.lua
Normal file
3
assets/scripts/core.lua
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
-- SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
-- Core engine scripts
|
||||
66
assets/shaders/cube.frag
Normal file
66
assets/shaders/cube.frag
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
#version 450
|
||||
|
||||
layout(location = 0) in vec3 frag_color;
|
||||
layout(location = 1) in vec3 frag_normal;
|
||||
layout(location = 2) in float frag_debug_tint;
|
||||
layout(location = 3) in vec3 frag_world_position;
|
||||
|
||||
layout(location = 0) out vec4 out_color;
|
||||
|
||||
// The block is declared identically in cube.vert. A push-constant block is a single object shared by every stage of the pipeline, so the two declarations must agree exactly even where a stage reads only part of it.
|
||||
layout(push_constant) uniform PushConstants {
|
||||
mat4 mvp;
|
||||
// xyz is the chunk's world offset; w is the debug-tint weight, 0.0 for normal rendering and 1.0 for a debug raster pass.
|
||||
vec4 chunk_offset;
|
||||
// xyz is the camera's world position; w is the horizontal distance at which fog reaches full opacity.
|
||||
vec4 fog;
|
||||
// rgb is the sky colour distant geometry fades into, matching the colour attachment's clear value; w is the vertical distance at which fog reaches full opacity.
|
||||
vec4 sky_color;
|
||||
} push_constants;
|
||||
|
||||
// Direction the light travels, pointing downward and across both horizontal axes so that no cubic face orientation receives exactly the same amount of light as another. A light aligned with an axis would leave two of the three visible faces of a cube indistinguishable.
|
||||
const vec3 LIGHT_DIRECTION = vec3(-0.4, -1.0, -0.3);
|
||||
|
||||
// Fraction of the albedo retained by a fully unlit face, standing in for bounced light until a global-illumination term exists. Without it, faces turned away from the light collapse to black and their silhouettes disappear against one another.
|
||||
const float AMBIENT = 0.25;
|
||||
|
||||
// Colour applied to debug raster passes, chosen to contrast with terrain and to remain legible when overlaid on filled geometry.
|
||||
const vec3 DEBUG_COLOR = vec3(1.0, 0.0, 1.0);
|
||||
|
||||
// Fraction of the fog end distance at which the fade begins. Below it geometry is drawn unfogged, which keeps the fog out of the region the player is actually looking at while leaving enough depth for the ramp to read as gradual rather than as a band.
|
||||
const float FOG_START_FRACTION = 0.6;
|
||||
|
||||
// Floor on the width of the fade band, guarding the division below against a caller that supplies a fog end distance of zero.
|
||||
const float MIN_FOG_RANGE = 1e-3;
|
||||
|
||||
// Returns the fog opacity for a surface `distance` from the camera along one axis, given the distance at which that axis reaches full opacity.
|
||||
//
|
||||
// The ramp is linear rather than exponential. Exponential fog approaches full opacity asymptotically without ever reaching it, so geometry stays faintly visible right up to the moment its chunk is unloaded, which is the pop the fog exists to conceal.
|
||||
float fog_ramp(float distance, float end) {
|
||||
float start = end * FOG_START_FRACTION;
|
||||
float range = max(end - start, MIN_FOG_RANGE);
|
||||
return clamp((distance - start) / range, 0.0, 1.0);
|
||||
}
|
||||
|
||||
void main() {
|
||||
// The interpolated normal is renormalised: the six face normals are unit length and constant across a quad, but interpolation across a triangle is not guaranteed to preserve that.
|
||||
vec3 normal = normalize(frag_normal);
|
||||
|
||||
// Lambertian term. The light vector is negated because LIGHT_DIRECTION points along the light's travel, whereas the dot product requires the direction from the surface toward the light. The clamp discards the negative half, where the face points away from the light.
|
||||
float diffuse = max(dot(normal, normalize(-LIGHT_DIRECTION)), 0.0);
|
||||
vec3 lit = frag_color * (AMBIENT + (1.0 - AMBIENT) * diffuse);
|
||||
|
||||
// The debug tint is applied after shading so debug passes draw flat and stay legible over the shaded geometry beneath them.
|
||||
vec3 shaded = mix(lit, DEBUG_COLOR, frag_debug_tint);
|
||||
|
||||
// The horizontal and vertical extents of the streaming region are ramped independently, because the region is a cylinder rather than a sphere and therefore reaches one frontier well before the other. Fading both against a single distance leaves the nearer frontier unfogged and fully visible.
|
||||
vec3 to_camera = frag_world_position - push_constants.fog.xyz;
|
||||
float fog_horizontal = fog_ramp(length(to_camera.xz), push_constants.fog.w);
|
||||
float fog_vertical = fog_ramp(abs(to_camera.y), push_constants.sky_color.a);
|
||||
|
||||
// Whichever frontier the surface is closer to determines the fade, so geometry is fully obscured before it crosses either one.
|
||||
float fog_factor = max(fog_horizontal, fog_vertical);
|
||||
|
||||
// Fog is applied after the debug tint so an overlay recedes together with the geometry it annotates instead of punching through the fade.
|
||||
out_color = vec4(mix(shaded, push_constants.sky_color.rgb, fog_factor), 1.0);
|
||||
}
|
||||
55
assets/shaders/cube.vert
Normal file
55
assets/shaders/cube.vert
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
|
||||
#version 450
|
||||
|
||||
layout(location = 0) in vec3 in_position;
|
||||
layout(location = 1) in vec3 in_color;
|
||||
layout(location = 2) in uint in_face;
|
||||
|
||||
layout(location = 0) out vec3 frag_color;
|
||||
layout(location = 1) out vec3 frag_normal;
|
||||
layout(location = 2) out float frag_debug_tint;
|
||||
layout(location = 3) out vec3 frag_world_position;
|
||||
|
||||
// The block is declared identically in cube.frag. A push-constant block is a single object shared by every stage of the pipeline, so the two declarations must agree exactly even where a stage reads only part of it.
|
||||
layout(push_constant) uniform PushConstants {
|
||||
mat4 mvp;
|
||||
// xyz is the chunk's world offset; w is the debug-tint weight, 0.0 for normal rendering and 1.0 for a debug raster pass.
|
||||
vec4 chunk_offset;
|
||||
// xyz is the camera's world position; w is the horizontal distance at which fog reaches full opacity.
|
||||
vec4 fog;
|
||||
// rgb is the sky colour distant geometry fades into, matching the colour attachment's clear value; w is the vertical distance at which fog reaches full opacity.
|
||||
vec4 sky_color;
|
||||
} push_constants;
|
||||
|
||||
// Size, in pixels, of the points emitted under VK_POLYGON_MODE_POINT. Sizes above 1.0 require the largePoints device feature.
|
||||
const float DEBUG_POINT_SIZE = 5.0;
|
||||
|
||||
// Outward normals of the six cubic face directions, indexed by the packed face attribute.
|
||||
const vec3 FACE_NORMALS[6] = vec3[6](
|
||||
vec3( 1.0, 0.0, 0.0),
|
||||
vec3(-1.0, 0.0, 0.0),
|
||||
vec3( 0.0, 1.0, 0.0),
|
||||
vec3( 0.0, -1.0, 0.0),
|
||||
vec3( 0.0, 0.0, 1.0),
|
||||
vec3( 0.0, 0.0, -1.0)
|
||||
);
|
||||
|
||||
void main() {
|
||||
// The chunk-local vertex is shifted into world space by the per-chunk offset before projection.
|
||||
vec3 world_position = in_position + push_constants.chunk_offset.xyz;
|
||||
gl_Position = push_constants.mvp * vec4(world_position, 1.0);
|
||||
|
||||
// Point size is consulted whenever the polygon mode is POINT; leaving it unwritten renders points of undefined size. It is ignored by the FILL and LINE pipelines, so it is written unconditionally.
|
||||
gl_PointSize = DEBUG_POINT_SIZE;
|
||||
|
||||
frag_color = in_color;
|
||||
|
||||
// Chunk placement is a pure translation, so a chunk-local face normal is already a world-space normal and no normal matrix is required.
|
||||
frag_normal = FACE_NORMALS[in_face];
|
||||
|
||||
// Shading and the debug tint both resolve in the fragment stage, so the weight is forwarded rather than applied here.
|
||||
frag_debug_tint = push_constants.chunk_offset.w;
|
||||
|
||||
// Forwarded for the fog term, which needs the distance from the camera to the shaded surface. Interpolating the world position is correct here because it is an affine function of the vertex positions.
|
||||
frag_world_position = world_position;
|
||||
}
|
||||
23
crates/client/Cargo.toml
Normal file
23
crates/client/Cargo.toml
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
[package]
|
||||
name = "client"
|
||||
license.workspace = true
|
||||
authors.workspace = true
|
||||
edition.workspace = true
|
||||
version.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
tracing.workspace = true
|
||||
tracing-subscriber.workspace = true
|
||||
winit = "0.30.13"
|
||||
renderer = { path = "../renderer" }
|
||||
glam.workspace = true
|
||||
raw-window-handle.workspace = true
|
||||
ash-window.workspace = true
|
||||
shared = { path = "../shared" }
|
||||
net = { version = "0.1.0", path = "../net" }
|
||||
crossbeam-channel = "0.5.16"
|
||||
sysinfo.workspace = true
|
||||
102
crates/client/src/camera.rs
Normal file
102
crates/client/src/camera.rs
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
//! Free-fly camera used to observe the world.
|
||||
//!
|
||||
//! The camera stores a world-space position and an orientation expressed as yaw and pitch angles. A view matrix is derived on demand from these values, and the orientation and position are advanced each frame from accumulated keyboard and mouse input.
|
||||
|
||||
use glam::{Mat4, Vec3};
|
||||
|
||||
use crate::InputState;
|
||||
|
||||
/// A free-flying camera driven by keyboard and mouse input.
|
||||
pub struct Camera {
|
||||
/// World-space position of the camera eye, measured in blocks.
|
||||
pub position: Vec3,
|
||||
/// Rotation about the world up axis (+Y), in radians. Controls left/right look.
|
||||
pub yaw: f32,
|
||||
/// Rotation above or below the horizon, in radians. Controls up/down look.
|
||||
pub pitch: f32,
|
||||
/// Translation speed applied to movement input, in blocks per second.
|
||||
pub speed: f32,
|
||||
/// Factor converting a unit of raw mouse motion into radians of rotation.
|
||||
pub sensitivity: f32,
|
||||
}
|
||||
|
||||
impl Camera {
|
||||
/// Maximum absolute pitch, held just under vertical to avoid the view flipping over.
|
||||
const PITCH_LIMIT: f32 = 1.553; // ~89 degrees expressed in radians.
|
||||
|
||||
/// Creates a camera at `position` facing the direction given by `yaw` and `pitch`.
|
||||
#[must_use]
|
||||
pub fn new(position: Vec3, yaw: f32, pitch: f32) -> Self {
|
||||
Self {
|
||||
position,
|
||||
yaw,
|
||||
pitch,
|
||||
speed: 20.0,
|
||||
sensitivity: 0.0025,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the normalised world-space direction the camera currently faces.
|
||||
#[must_use]
|
||||
pub fn forward(&self) -> Vec3 {
|
||||
// Spherical-to-Cartesian conversion: yaw sweeps around +Y, pitch tilts up and down.
|
||||
Vec3::new(
|
||||
self.yaw.cos() * self.pitch.cos(),
|
||||
self.pitch.sin(),
|
||||
self.yaw.sin() * self.pitch.cos(),
|
||||
)
|
||||
.normalize()
|
||||
}
|
||||
|
||||
/// Builds the right-handed view matrix for the current position and orientation.
|
||||
#[must_use]
|
||||
pub fn view_matrix(&self) -> Mat4 {
|
||||
glam::camera::rh::view::look_at_mat4(self.position, self.position + self.forward(), Vec3::Y)
|
||||
}
|
||||
|
||||
/// Advances the camera by a single frame, applying `input` accumulated over `dt` seconds.
|
||||
pub fn update(&mut self, input: &InputState, dt: f32) {
|
||||
// Apply accumulated mouse motion to the orientation. A downward mouse delta (positive y) lowers the pitch, so the vertical term is subtracted.
|
||||
#[expect(
|
||||
clippy::cast_possible_truncation,
|
||||
reason = "mouse deltas are small; f32 precision is sufficient for camera input"
|
||||
)]
|
||||
{
|
||||
self.yaw += input.mouse_delta.0 as f32 * self.sensitivity;
|
||||
self.pitch -= input.mouse_delta.1 as f32 * self.sensitivity;
|
||||
}
|
||||
self.pitch = self.pitch.clamp(-Self::PITCH_LIMIT, Self::PITCH_LIMIT);
|
||||
|
||||
// Derive the movement basis from the current facing. The right vector is horizontal because it is the cross product of the facing direction with the world up axis.
|
||||
let forward = self.forward();
|
||||
let right = forward.cross(Vec3::Y).normalize();
|
||||
|
||||
// Accumulate a movement direction from the currently held keys.
|
||||
let mut direction = Vec3::ZERO;
|
||||
if input.forward {
|
||||
direction += forward;
|
||||
}
|
||||
if input.backward {
|
||||
direction -= forward;
|
||||
}
|
||||
if input.right {
|
||||
direction += right;
|
||||
}
|
||||
if input.left {
|
||||
direction -= right;
|
||||
}
|
||||
if input.up {
|
||||
direction += Vec3::Y;
|
||||
}
|
||||
if input.down {
|
||||
direction -= Vec3::Y;
|
||||
}
|
||||
|
||||
// Normalising keeps diagonal movement the same speed as axis-aligned movement. The guard avoids normalising a zero vector, which would produce NaN when idle.
|
||||
if direction.length_squared() > 0.0 {
|
||||
self.position += direction.normalize() * self.speed * dt;
|
||||
}
|
||||
}
|
||||
}
|
||||
480
crates/client/src/chunks.rs
Normal file
480
crates/client/src/chunks.rs
Normal file
|
|
@ -0,0 +1,480 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
//! Client-side chunk streaming around the camera.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::Arc;
|
||||
|
||||
use renderer::vertex::Vertex;
|
||||
use renderer::{MeshKey, RendererError};
|
||||
use shared::protocol::chunk::ChunkMessage;
|
||||
use shared::world::{CHUNK_SIZE, Chunk, ChunkPos};
|
||||
use tracing::error;
|
||||
|
||||
use crate::mesh_pool::{JobGen, MeshJob, MeshPool, MeshResult};
|
||||
|
||||
/// Radius, in chunks, of the region kept resident around the camera center. Also the radius the client subscribes with, so the server's resident set matches the client's.
|
||||
// TODO: make configurable / drive from view-distance setting.
|
||||
pub const LOAD_RADIUS: i32 = 16;
|
||||
|
||||
/// Horizontal extent, in blocks, of the resident region around the camera.
|
||||
#[expect(
|
||||
clippy::cast_precision_loss,
|
||||
reason = "the radius and chunk size are small compile-time constants, exact as f32"
|
||||
)]
|
||||
pub const LOAD_DISTANCE: f32 = LOAD_RADIUS as f32 * CHUNK_SIZE as f32;
|
||||
|
||||
/// Vertical extent, in blocks, of the resident region around the camera.
|
||||
///
|
||||
/// The streaming region is a cylinder half as tall as it is wide (see [`desired_chunks`]), so it reaches its vertical frontier at half the horizontal distance. Fading both extents against [`LOAD_DISTANCE`] leaves the cylinder's caps unfogged and their unloaded edge plainly visible from above or below, so the renderer ramps the two independently. The halving uses integer division to track [`desired_chunks`] exactly, including for odd radii.
|
||||
#[expect(
|
||||
clippy::cast_precision_loss,
|
||||
reason = "the radius and chunk size are small compile-time constants, exact as f32"
|
||||
)]
|
||||
pub const LOAD_DISTANCE_VERTICAL: f32 = (LOAD_RADIUS / 2) as f32 * CHUNK_SIZE as f32;
|
||||
|
||||
/// Maximum number of chunk deliveries materialized in a single call to [`ChunkManager::update`], bounding per-frame materialization work. Deliveries beyond the budget remain queued in the transport for the next frame.
|
||||
const LOADS_PER_UPDATE: usize = 4;
|
||||
|
||||
/// Maximum number of mesh jobs dispatched to the worker pool per call to [`ChunkManager::update`], draining the pending re-mesh set under a bound so a burst of deliveries does not flood the pool in a single frame. One delivery can enqueue up to seven mesh jobs (itself plus six neighbours), so this budget exceeds [`LOADS_PER_UPDATE`]. Finished meshes are ingested without a per-frame bound, since uploading already-computed geometry is cheap relative to generating it.
|
||||
const MESHES_PER_UPDATE: usize = 16;
|
||||
|
||||
/// The six face-adjacent neighbour offsets, in chunk coordinates. The order matches the neighbour array carried by [`MeshJob`]: `[+X, -X, +Y, -Y, +Z, -Z]`.
|
||||
const NEIGHBOR_OFFSETS: [(i32, i32, i32); 6] = [
|
||||
(1, 0, 0),
|
||||
(-1, 0, 0),
|
||||
(0, 1, 0),
|
||||
(0, -1, 0),
|
||||
(0, 0, 1),
|
||||
(0, 0, -1),
|
||||
];
|
||||
|
||||
/// Sink that receives finished chunk meshes for upload.
|
||||
///
|
||||
/// The production sink is the Vulkan [`Renderer`](renderer::Renderer); the abstraction exists so the ingest pipeline can be exercised against a recording double in tests, which have no GPU. Method signatures mirror the renderer's exactly so the production `impl` is a direct forward.
|
||||
pub trait MeshSink {
|
||||
/// Uploads (or replaces) the mesh identified by `key`.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`RendererError`] when the underlying implementation fails to allocate or write the GPU buffers for the mesh.
|
||||
fn insert_mesh(
|
||||
&mut self,
|
||||
key: MeshKey,
|
||||
vertices: &[Vertex],
|
||||
indices: &[u32],
|
||||
world_offset: [f32; 3],
|
||||
) -> Result<(), RendererError>;
|
||||
|
||||
/// Removes any mesh currently associated with `key`; a no-op when none exists.
|
||||
fn remove_mesh(&mut self, key: MeshKey);
|
||||
}
|
||||
|
||||
impl MeshSink for renderer::Renderer {
|
||||
fn insert_mesh(
|
||||
&mut self,
|
||||
key: MeshKey,
|
||||
vertices: &[Vertex],
|
||||
indices: &[u32],
|
||||
world_offset: [f32; 3],
|
||||
) -> Result<(), RendererError> {
|
||||
renderer::Renderer::insert_mesh(self, key, vertices, indices, world_offset)
|
||||
}
|
||||
|
||||
fn remove_mesh(&mut self, key: MeshKey) {
|
||||
renderer::Renderer::remove_mesh(self, key);
|
||||
}
|
||||
}
|
||||
|
||||
/// Approximate resident footprint of one chunk, in bytes: a full `CHUNK_SIZE³` volume of `u16` block identifiers.
|
||||
///
|
||||
/// Ignores the `HashMap` entry and [`Arc`] header overheads, which are negligible beside the volume itself.
|
||||
const CHUNK_RESIDENT_BYTES: u64 = (CHUNK_SIZE as u64).pow(3) * 2;
|
||||
|
||||
/// A snapshot of the streaming pipeline's state and cumulative throughput.
|
||||
///
|
||||
/// Every field is a plain value copied out of [`ChunkManager`] at the moment of the call; nothing is retained or shared, so a reader on a slower cadence than the frame loop observes one self-consistent instant.
|
||||
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
|
||||
pub struct ChunkStats {
|
||||
/// Chunks currently held in memory, each retaining its full voxel volume.
|
||||
pub resident: usize,
|
||||
/// Positions queued for re-meshing but not yet dispatched to the worker pool.
|
||||
pub pending_remesh: usize,
|
||||
/// Positions with a mesh job outstanding in the worker pool.
|
||||
pub in_flight: usize,
|
||||
/// Streaming radius, in chunks, currently in force.
|
||||
pub load_radius: i32,
|
||||
/// Size of the set of positions the streaming region wants resident. Residency lagging behind this figure indicates the server has not yet delivered the remainder.
|
||||
pub desired: usize,
|
||||
/// Chunks materialized from server deliveries since startup.
|
||||
pub loaded_total: u64,
|
||||
/// Chunks removed on the server's authoritative drop since startup.
|
||||
pub dropped_total: u64,
|
||||
/// Chunks evicted by the client's own radius check since startup, independently of any server drop.
|
||||
pub evicted_total: u64,
|
||||
/// Mesh jobs handed to the worker pool since startup.
|
||||
pub dispatched_total: u64,
|
||||
/// Finished meshes that were still current on return and were therefore uploaded since startup. The shortfall against `dispatched_total` is work superseded by a newer job or invalidated by eviction.
|
||||
pub applied_total: u64,
|
||||
/// Worker threads in the meshing pool.
|
||||
pub mesh_workers: usize,
|
||||
/// Estimated memory held by the resident chunk set, in bytes.
|
||||
pub resident_bytes: u64,
|
||||
}
|
||||
|
||||
/// Tracks which server-streamed chunks are resident and orchestrates neighbour-aware background meshing.
|
||||
pub struct ChunkManager {
|
||||
/// Resident chunks keyed by position, retained so the mesher can sample voxels across chunk boundaries. Stored behind [`Arc`] so a chunk can be handed to a worker thread without copying its 64 KiB volume.
|
||||
// TODO: a resident Chunk is 32³ × 2 bytes = 64 KiB; at LOAD_RADIUS = 8 the resident set is thousands of chunks (hundreds of MiB). A follow-up can store only the six 32×32 boundary planes per chunk instead of the full volume.
|
||||
resident: HashMap<ChunkPos, Arc<Chunk>>,
|
||||
/// Positions whose mesh must be rebuilt, accumulated across frames and dispatched under [`MESHES_PER_UPDATE`]. Held as a set so a burst of deliveries re-meshes each affected neighbour at most once.
|
||||
pending_remesh: HashSet<ChunkPos>,
|
||||
/// Positions with a mesh job currently outstanding, mapped to the generation of that job. A returned mesh is applied only when its generation still matches, so meshes superseded by a re-dispatch (or by eviction) are discarded rather than uploaded stale.
|
||||
in_flight: HashMap<ChunkPos, JobGen>,
|
||||
/// The generation stamped on the next dispatched job. Global and strictly increasing across all positions, so no two dispatches ever share a token; see [`JobGen`].
|
||||
next_gen: JobGen,
|
||||
/// Background worker pool that turns chunks into CPU geometry off the winit thread.
|
||||
pool: MeshPool,
|
||||
/// Reused all-air baseline that server [`ChunkData`](shared::world::ChunkData) diffs are materialized against.
|
||||
baseline: Chunk,
|
||||
/// Running totals of pipeline throughput since startup, reported through [`ChunkManager::stats`].
|
||||
totals: ChunkTotals,
|
||||
}
|
||||
|
||||
/// Cumulative counts of the work the streaming pipeline has performed since startup.
|
||||
///
|
||||
/// Kept as a separate struct so the per-frame counters already computed inside [`ChunkManager::update`] fold into one place rather than becoming five loose fields on the manager.
|
||||
#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)]
|
||||
struct ChunkTotals {
|
||||
/// Chunks materialized from server deliveries.
|
||||
loaded: u64,
|
||||
/// Chunks removed on the server's authoritative drop.
|
||||
dropped: u64,
|
||||
/// Chunks evicted by the client's own radius check.
|
||||
evicted: u64,
|
||||
/// Mesh jobs handed to the worker pool.
|
||||
dispatched: u64,
|
||||
/// Finished meshes uploaded because they were still current on return.
|
||||
applied: u64,
|
||||
}
|
||||
|
||||
impl ChunkTotals {
|
||||
/// Folds one frame's per-category counts into the running totals.
|
||||
///
|
||||
/// Saturating addition is used throughout: these are monotonic diagnostic counters, and pinning them at `u64::MAX` is preferable to an overflow panic in the frame loop. Reaching the bound would require more chunk operations than any session performs.
|
||||
fn accumulate(
|
||||
&mut self,
|
||||
loaded: usize,
|
||||
dropped: usize,
|
||||
evicted: usize,
|
||||
dispatched: usize,
|
||||
applied: usize,
|
||||
) {
|
||||
self.loaded = self.loaded.saturating_add(loaded as u64);
|
||||
self.dropped = self.dropped.saturating_add(dropped as u64);
|
||||
self.evicted = self.evicted.saturating_add(evicted as u64);
|
||||
self.dispatched = self.dispatched.saturating_add(dispatched as u64);
|
||||
self.applied = self.applied.saturating_add(applied as u64);
|
||||
}
|
||||
}
|
||||
|
||||
impl ChunkManager {
|
||||
/// Creates a manager with no chunks yet resident, spawning the background mesh worker pool.
|
||||
#[must_use]
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
resident: HashMap::new(),
|
||||
pending_remesh: HashSet::new(),
|
||||
in_flight: HashMap::new(),
|
||||
next_gen: JobGen::FIRST,
|
||||
pool: MeshPool::new(),
|
||||
baseline: Chunk::default(),
|
||||
totals: ChunkTotals::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Snapshots the streaming pipeline's current state and cumulative throughput.
|
||||
///
|
||||
/// `center` is the chunk the streaming region is currently anchored to, and is needed only to size the desired set; it is not retained.
|
||||
#[must_use]
|
||||
pub fn stats(&self, center: ChunkPos) -> ChunkStats {
|
||||
ChunkStats {
|
||||
resident: self.resident.len(),
|
||||
pending_remesh: self.pending_remesh.len(),
|
||||
in_flight: self.in_flight.len(),
|
||||
load_radius: LOAD_RADIUS,
|
||||
desired: desired_chunks(center, LOAD_RADIUS).len(),
|
||||
loaded_total: self.totals.loaded,
|
||||
dropped_total: self.totals.dropped,
|
||||
evicted_total: self.totals.evicted,
|
||||
dispatched_total: self.totals.dispatched,
|
||||
applied_total: self.totals.applied,
|
||||
mesh_workers: self.pool.worker_count(),
|
||||
resident_bytes: self.resident.len() as u64 * CHUNK_RESIDENT_BYTES,
|
||||
}
|
||||
}
|
||||
|
||||
/// Advances the streaming pipeline for one frame: ingests finished meshes from the pool, evicts chunks outside the load radius around `center`, applies queued server deliveries under a materialization budget, then dispatches pending re-mesh jobs under a dispatch budget.
|
||||
///
|
||||
/// The client's own radius eviction runs independently of the server's authoritative `Drop`, so memory stays bounded even if the server is slow to drop chunks that leave the region.
|
||||
pub fn update(
|
||||
&mut self,
|
||||
center: ChunkPos,
|
||||
deliveries: &mut net::ChunkStream,
|
||||
sink: &mut impl MeshSink,
|
||||
) {
|
||||
let applied = self.drain_results(sink);
|
||||
let unloaded = self.unload_outside(center, sink);
|
||||
let (loaded, dropped) = self.apply_deliveries(deliveries, sink);
|
||||
let dispatched = self.dispatch_pending();
|
||||
|
||||
self.totals
|
||||
.accumulate(loaded, dropped, unloaded, dispatched, applied);
|
||||
}
|
||||
|
||||
/// Ingests every finished mesh currently available from the pool, uploading the ones that are still current and discarding superseded or evicted ones. Returns the number uploaded.
|
||||
fn drain_results(&mut self, sink: &mut impl MeshSink) -> usize {
|
||||
let mut applied = 0;
|
||||
while let Some(result) = self.pool.poll() {
|
||||
if self.apply_result(&result, sink) {
|
||||
applied += 1;
|
||||
}
|
||||
}
|
||||
applied
|
||||
}
|
||||
|
||||
/// Uploads a single finished mesh when it is still current, returning whether it was uploaded.
|
||||
///
|
||||
/// A result is current when its position is still resident and its generation matches the latest job dispatched for that position (see [`should_apply`]). On a match the in-flight entry is cleared; otherwise the result is dropped and any newer outstanding job for the position is left untouched.
|
||||
fn apply_result(&mut self, result: &MeshResult, sink: &mut impl MeshSink) -> bool {
|
||||
if !should_apply(
|
||||
result.pos,
|
||||
result.generation,
|
||||
|pos| self.resident.contains_key(&pos),
|
||||
&self.in_flight,
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
self.in_flight.remove(&result.pos);
|
||||
upload_result(result, sink);
|
||||
true
|
||||
}
|
||||
|
||||
/// Applies up to [`LOADS_PER_UPDATE`] chunk deliveries plus any interleaved drops, returning the counts of chunks loaded and dropped.
|
||||
///
|
||||
/// Delivered chunks are materialized and retained; drops remove the chunk from residency, from any in-flight tracking, and from the renderer. Both kinds enqueue the affected neighbourhood for re-meshing.
|
||||
fn apply_deliveries(
|
||||
&mut self,
|
||||
deliveries: &mut net::ChunkStream,
|
||||
sink: &mut impl MeshSink,
|
||||
) -> (usize, usize) {
|
||||
let mut loaded = Vec::new();
|
||||
let mut dropped = Vec::new();
|
||||
// Only chunk deliveries count against the budget; drops are cheap and always applied.
|
||||
while loaded.len() < LOADS_PER_UPDATE {
|
||||
match deliveries.try_recv() {
|
||||
Ok(ChunkMessage::Chunk { pos, data }) => {
|
||||
let chunk = Arc::new(data.materialize(&self.baseline));
|
||||
self.resident.insert(pos, chunk);
|
||||
loaded.push(pos);
|
||||
}
|
||||
Ok(ChunkMessage::Drop { pos }) => {
|
||||
if self.resident.remove(&pos).is_some() {
|
||||
self.in_flight.remove(&pos);
|
||||
sink.remove_mesh((pos.x, pos.y, pos.z));
|
||||
dropped.push(pos);
|
||||
}
|
||||
}
|
||||
// Empty or disconnected: nothing more to apply this frame.
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
|
||||
self.queue_remesh(&loaded, &dropped);
|
||||
(loaded.len(), dropped.len())
|
||||
}
|
||||
|
||||
/// Evicts every resident chunk outside the load radius around `center`, returning the number removed.
|
||||
///
|
||||
/// Each evicted chunk is removed from residency, from in-flight tracking, and from the renderer; its resident neighbours have a boundary toward it that is now exposed, so they are enqueued for re-meshing.
|
||||
fn unload_outside(&mut self, center: ChunkPos, sink: &mut impl MeshSink) -> usize {
|
||||
let desired = desired_chunks(center, LOAD_RADIUS);
|
||||
let stale: Vec<ChunkPos> = self
|
||||
.resident
|
||||
.keys()
|
||||
.filter(|pos| !desired.contains(pos))
|
||||
.copied()
|
||||
.collect();
|
||||
for pos in &stale {
|
||||
sink.remove_mesh((pos.x, pos.y, pos.z));
|
||||
self.resident.remove(pos);
|
||||
self.in_flight.remove(pos);
|
||||
}
|
||||
self.queue_remesh(&[], &stale);
|
||||
stale.len()
|
||||
}
|
||||
|
||||
/// Adds the chunks affected by `loaded` and `dropped` to the pending re-mesh set.
|
||||
fn queue_remesh(&mut self, loaded: &[ChunkPos], dropped: &[ChunkPos]) {
|
||||
let targets = remesh_targets(loaded, dropped, |pos| self.resident.contains_key(&pos));
|
||||
self.pending_remesh.extend(targets);
|
||||
}
|
||||
|
||||
/// Dispatches up to [`MESHES_PER_UPDATE`] pending re-mesh jobs to the worker pool, returning the number dispatched.
|
||||
///
|
||||
/// Each dispatched position snapshots its chunk and current resident neighbours behind [`Arc`]s, is stamped with a fresh generation, and is recorded as in-flight (superseding any previous outstanding job for it). Positions no longer resident (dropped after being enqueued) are skipped without dispatch.
|
||||
fn dispatch_pending(&mut self) -> usize {
|
||||
// Take a bounded batch out of the set; the remainder stays queued for later frames.
|
||||
let batch: Vec<ChunkPos> = self
|
||||
.pending_remesh
|
||||
.iter()
|
||||
.take(MESHES_PER_UPDATE)
|
||||
.copied()
|
||||
.collect();
|
||||
|
||||
let mut dispatched = 0;
|
||||
for pos in batch {
|
||||
self.pending_remesh.remove(&pos);
|
||||
let Some(chunk) = self.resident.get(&pos) else {
|
||||
continue;
|
||||
};
|
||||
let chunk = Arc::clone(chunk);
|
||||
let neighbors = self.neighbor_arcs(pos);
|
||||
let generation = self.bump_gen();
|
||||
self.in_flight.insert(pos, generation);
|
||||
self.pool.dispatch(MeshJob {
|
||||
pos,
|
||||
generation,
|
||||
chunk,
|
||||
neighbors,
|
||||
});
|
||||
dispatched += 1;
|
||||
}
|
||||
dispatched
|
||||
}
|
||||
|
||||
/// Snapshots the six face-adjacent resident chunks of `pos` as [`Arc`] handles, ordered to match [`NEIGHBOR_OFFSETS`]. Absent neighbours are `None`.
|
||||
fn neighbor_arcs(&self, pos: ChunkPos) -> [Option<Arc<Chunk>>; 6] {
|
||||
NEIGHBOR_OFFSETS.map(|(dx, dy, dz)| {
|
||||
self.resident
|
||||
.get(&ChunkPos::new(pos.x + dx, pos.y + dy, pos.z + dz))
|
||||
.map(Arc::clone)
|
||||
})
|
||||
}
|
||||
|
||||
/// Returns a fresh, never-before-used generation and advances the counter.
|
||||
fn bump_gen(&mut self) -> JobGen {
|
||||
let current = self.next_gen;
|
||||
self.next_gen = self.next_gen.next();
|
||||
current
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ChunkManager {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// Reports whether a finished mesh should be uploaded.
|
||||
///
|
||||
/// A mesh is current, and therefore applied, only when its position is still wanted (resident) and the generation recorded as in-flight for that position still equals the mesh's own generation. A missing in-flight entry (the position was evicted) or a mismatched generation (a newer job superseded this one) both mean the result is stale and must be discarded.
|
||||
fn should_apply(
|
||||
pos: ChunkPos,
|
||||
generation: JobGen,
|
||||
is_wanted: impl Fn(ChunkPos) -> bool,
|
||||
in_flight: &HashMap<ChunkPos, JobGen>,
|
||||
) -> bool {
|
||||
is_wanted(pos) && in_flight.get(&pos) == Some(&generation)
|
||||
}
|
||||
|
||||
/// Uploads a finished mesh to the sink, or clears the slot when the mesh is empty.
|
||||
///
|
||||
/// A chunk that meshes to no geometry (all air, or fully enclosed by solid neighbours) is removed from the sink rather than uploaded, since a zero-length buffer is invalid; this also clears any mesh a previous state had left there.
|
||||
fn upload_result(result: &MeshResult, sink: &mut impl MeshSink) {
|
||||
let pos = result.pos;
|
||||
let key = (pos.x, pos.y, pos.z);
|
||||
|
||||
if result.indices.is_empty() {
|
||||
sink.remove_mesh(key);
|
||||
return;
|
||||
}
|
||||
|
||||
// Chunk coordinates and CHUNK_SIZE are small and represent exactly as f32.
|
||||
#[expect(
|
||||
clippy::cast_precision_loss,
|
||||
reason = "chunk coordinates stay well within f32's exact-integer range"
|
||||
)]
|
||||
let world_offset = {
|
||||
let size = CHUNK_SIZE as f32;
|
||||
[
|
||||
pos.x as f32 * size,
|
||||
pos.y as f32 * size,
|
||||
pos.z as f32 * size,
|
||||
]
|
||||
};
|
||||
|
||||
if let Err(e) = sink.insert_mesh(key, &result.vertices, &result.indices, world_offset) {
|
||||
error!(?pos, "failed to upload chunk mesh: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the six face-adjacent neighbour positions of `pos`.
|
||||
fn neighbor_positions(pos: ChunkPos) -> [ChunkPos; 6] {
|
||||
NEIGHBOR_OFFSETS.map(|(dx, dy, dz)| ChunkPos::new(pos.x + dx, pos.y + dy, pos.z + dz))
|
||||
}
|
||||
|
||||
/// Computes the deduplicated set of resident chunks whose mesh must be rebuilt after a batch of loads and drops.
|
||||
///
|
||||
/// A newly-loaded chunk contributes itself (when resident) and each of its resident neighbours, whose boundary toward it may now be culled. A dropped chunk contributes only its resident neighbours, whose boundary toward it is re-exposed; the dropped chunk itself is gone and is never a target. `is_resident` reports whether a position is currently resident.
|
||||
fn remesh_targets(
|
||||
loaded: &[ChunkPos],
|
||||
dropped: &[ChunkPos],
|
||||
is_resident: impl Fn(ChunkPos) -> bool,
|
||||
) -> HashSet<ChunkPos> {
|
||||
let mut targets = HashSet::new();
|
||||
for &pos in loaded {
|
||||
if is_resident(pos) {
|
||||
targets.insert(pos);
|
||||
}
|
||||
for neighbor in neighbor_positions(pos) {
|
||||
if is_resident(neighbor) {
|
||||
targets.insert(neighbor);
|
||||
}
|
||||
}
|
||||
}
|
||||
for &pos in dropped {
|
||||
for neighbor in neighbor_positions(pos) {
|
||||
if is_resident(neighbor) {
|
||||
targets.insert(neighbor);
|
||||
}
|
||||
}
|
||||
}
|
||||
targets
|
||||
}
|
||||
|
||||
/// Returns the set of chunk positions within the streaming cylinder around `center`.
|
||||
///
|
||||
/// The region is a disc of `radius` chunks in the horizontal XZ plane and half that extent in Y, matching the flatter vertical shape of the playable world. This mirrors the server's `world_server::cylinder_chunks`.
|
||||
#[must_use]
|
||||
pub fn desired_chunks(center: ChunkPos, radius: i32) -> HashSet<ChunkPos> {
|
||||
let mut out = HashSet::new();
|
||||
for x in center.x - radius..=center.x + radius {
|
||||
for z in center.z - radius..=center.z + radius {
|
||||
let dx = x - center.x;
|
||||
let dz = z - center.z;
|
||||
|
||||
// Keep only the columns whose XZ distance falls within the disc.
|
||||
if dx * dx + dz * dz <= radius * radius {
|
||||
for y in center.y - radius / 2..=center.y + radius / 2 {
|
||||
out.insert(ChunkPos::new(x, y, z));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tests/chunks.rs"]
|
||||
mod tests;
|
||||
111
crates/client/src/debug.rs
Normal file
111
crates/client/src/debug.rs
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
//! Debug-only input handling, kept separate from the gameplay input path.
|
||||
//!
|
||||
//! Debug affordances are bound behind a modifier chord so they cannot collide with movement keys: [`DEBUG_MODIFIER`] (F1) is held, and a second key selects the affordance. The currently bound chords are:
|
||||
//!
|
||||
//! - **F1 + V**: filled terrain with vertex points overlaid, showing where the mesher placed geometry without losing the surface.
|
||||
//! - **F1 + B**: filled terrain with the triangle edges overlaid, showing the size and shape of the emitted quads.
|
||||
//! - **F1 + I**: the debug statistics panel.
|
||||
//!
|
||||
//! Holding a [`SOLO_MODIFIER`] (either Shift) as well drops the filled pass, leaving the debug geometry alone against the clear colour: **F1 + Shift + V** for points only, **F1 + Shift + B** for wireframe only.
|
||||
//!
|
||||
//! Each chord toggles: pressing the chord for the active mode returns to [`RenderMode::Filled`].
|
||||
|
||||
use renderer::RenderMode;
|
||||
use winit::keyboard::KeyCode;
|
||||
|
||||
/// The key that must be held for a debug chord to be recognised.
|
||||
const DEBUG_MODIFIER: KeyCode = KeyCode::F1;
|
||||
|
||||
/// The keys that, held alongside [`DEBUG_MODIFIER`], select the solo form of a debug view. Both shifts are accepted so the chord is reachable with either hand.
|
||||
const SOLO_MODIFIER: [KeyCode; 2] = [KeyCode::ShiftLeft, KeyCode::ShiftRight];
|
||||
|
||||
/// The key that, held alongside [`DEBUG_MODIFIER`], selects the vertex-point view.
|
||||
const VERTEX_POINTS_OVERLAY_KEY: KeyCode = KeyCode::KeyV;
|
||||
|
||||
/// The key that, held alongside [`DEBUG_MODIFIER`], selects the wireframe view.
|
||||
const WIREFRAME_OVERLAY_KEY: KeyCode = KeyCode::KeyB;
|
||||
|
||||
/// The key that, held alongside [`DEBUG_MODIFIER`], toggles the statistics panel.
|
||||
const STATS_KEY: KeyCode = KeyCode::KeyI;
|
||||
|
||||
/// A debug operation requested by the input layer, applied by the caller.
|
||||
///
|
||||
/// The layer deliberately returns an intent rather than acting directly, so it owns no renderer or window handles and stays a pure function of key events.
|
||||
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum DebugAction {
|
||||
/// Applies the given rasterisation mode to the renderer.
|
||||
SetRenderMode(RenderMode),
|
||||
/// Enables or disables emission of the statistics panel.
|
||||
SetStatsOverlay(bool),
|
||||
}
|
||||
|
||||
/// Owns debug-only input state and translates key events into [`DebugAction`]s.
|
||||
#[derive(Default)]
|
||||
pub(crate) struct DebugControls {
|
||||
/// Whether [`DEBUG_MODIFIER`] is currently held. Chords are recognised only while this is set.
|
||||
modifier_held: bool,
|
||||
/// Whether a [`SOLO_MODIFIER`] is currently held, selecting the solo form of the chord.
|
||||
solo_held: bool,
|
||||
/// The rasterisation mode most recently requested, used to make each chord a toggle back to [`RenderMode::Filled`].
|
||||
render_mode: RenderMode,
|
||||
/// Whether the statistics panel is being emitted.
|
||||
stats_enabled: bool,
|
||||
}
|
||||
|
||||
impl DebugControls {
|
||||
/// Translates one key event into a debug action, updating internal state.
|
||||
///
|
||||
/// Returns [`None`] when the event is not part of a debug chord, which is the common case; the caller then handles the key normally. Actions fire on the press edge only, so one physical tap toggles once rather than once per press and once per release.
|
||||
pub(crate) fn handle_key(&mut self, code: KeyCode, pressed: bool) -> Option<DebugAction> {
|
||||
if code == DEBUG_MODIFIER {
|
||||
self.modifier_held = pressed;
|
||||
return None;
|
||||
}
|
||||
|
||||
// A solo modifier is tracked unconditionally rather than only while the debug modifier is held, so its state is correct whichever of the two is pressed first.
|
||||
if SOLO_MODIFIER.contains(&code) {
|
||||
self.solo_held = pressed;
|
||||
return None;
|
||||
}
|
||||
|
||||
if !pressed || !self.modifier_held {
|
||||
return None;
|
||||
}
|
||||
|
||||
// The statistics chord is resolved before the raster table so the two axes never contend for a key. The solo modifier selects between overlaid and standalone geometry and has no meaning for a panel that draws none, so it is ignored here.
|
||||
if code == STATS_KEY {
|
||||
self.stats_enabled = !self.stats_enabled;
|
||||
return Some(DebugAction::SetStatsOverlay(self.stats_enabled));
|
||||
}
|
||||
|
||||
let requested = render_mode_for_key(code, self.solo_held)?;
|
||||
|
||||
// Re-pressing the chord for the active mode returns to the normal path, so a single chord both enables and disables its mode.
|
||||
self.render_mode = if self.render_mode == requested {
|
||||
RenderMode::Filled
|
||||
} else {
|
||||
requested
|
||||
};
|
||||
|
||||
Some(DebugAction::SetRenderMode(self.render_mode))
|
||||
}
|
||||
}
|
||||
|
||||
/// Maps a chord key, and whether a [`SOLO_MODIFIER`] is held, to the render mode it selects. Returns [`None`] if the key is unbound.
|
||||
///
|
||||
/// This is the single table a new rasterisation debug mode is added to: one key, one overlaid form, one solo form.
|
||||
const fn render_mode_for_key(code: KeyCode, solo: bool) -> Option<RenderMode> {
|
||||
match (code, solo) {
|
||||
(VERTEX_POINTS_OVERLAY_KEY, false) => Some(RenderMode::FilledPoints),
|
||||
(VERTEX_POINTS_OVERLAY_KEY, true) => Some(RenderMode::Points),
|
||||
(WIREFRAME_OVERLAY_KEY, false) => Some(RenderMode::FilledWireframe),
|
||||
(WIREFRAME_OVERLAY_KEY, true) => Some(RenderMode::Wireframe),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tests/debug.rs"]
|
||||
mod tests;
|
||||
449
crates/client/src/main.rs
Normal file
449
crates/client/src/main.rs
Normal file
|
|
@ -0,0 +1,449 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
//! Main entry point for the Synvael client.
|
||||
//!
|
||||
//! This crate handles window creation, input processing, and drives the renderer to display the game world.
|
||||
|
||||
mod camera;
|
||||
mod chunks;
|
||||
mod debug;
|
||||
mod mesh_pool;
|
||||
mod stats;
|
||||
|
||||
use std::time::Instant;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use camera::Camera;
|
||||
use glam::Vec3;
|
||||
use raw_window_handle::{HasDisplayHandle, HasWindowHandle};
|
||||
use shared::protocol::authority::{AuthorityMessage, ServerStats};
|
||||
use shared::session::ServerKind;
|
||||
use stats::{FrameAccumulator, HostMonitor, ServerIdentity, Snapshot};
|
||||
use tracing::{error, info, warn};
|
||||
use winit::application::ApplicationHandler;
|
||||
use winit::event::{DeviceEvent, DeviceId, ElementState, WindowEvent};
|
||||
use winit::event_loop::{ActiveEventLoop, ControlFlow, EventLoop};
|
||||
use winit::keyboard::{KeyCode, PhysicalKey};
|
||||
use winit::window::{CursorGrabMode, Window, WindowId};
|
||||
|
||||
/// Transient per-frame input state sampled from window and device events.
|
||||
///
|
||||
/// Keyboard fields hold whether a movement key is currently pressed. `mouse_delta` accumulates raw pointer motion between frames and is consumed (reset to zero) once applied to the camera.
|
||||
#[expect(
|
||||
clippy::struct_excessive_bools,
|
||||
reason = "per-key held states are independent; a flat bool struct is the clearest representation"
|
||||
)]
|
||||
#[derive(Default)]
|
||||
struct InputState {
|
||||
/// Whether the "move forward" key (W) is held.
|
||||
forward: bool,
|
||||
/// Whether the "move backward" key (S) is held.
|
||||
backward: bool,
|
||||
/// Whether the "strafe left" key (A) is held.
|
||||
left: bool,
|
||||
/// Whether the "strafe right" key (D) is held.
|
||||
right: bool,
|
||||
/// Whether the "move up" key (Space) is held.
|
||||
up: bool,
|
||||
/// Whether the "move down" key (Left Shift) is held.
|
||||
down: bool,
|
||||
/// Accumulated raw mouse motion (x, y) since the last frame, in device units.
|
||||
mouse_delta: (f64, f64),
|
||||
}
|
||||
|
||||
/// Top-level application state driving the window, renderer, and camera.
|
||||
struct App {
|
||||
/// The Vulkan renderer, initialised once the window exists.
|
||||
renderer: Option<renderer::Renderer>,
|
||||
/// The application window, created on resume.
|
||||
window: Option<Window>,
|
||||
/// The free-fly camera supplying the view matrix each frame.
|
||||
camera: Camera,
|
||||
/// The current keyboard and mouse input state.
|
||||
input: InputState,
|
||||
/// Debug-only key handling, kept separate from the gameplay input path.
|
||||
debug: debug::DebugControls,
|
||||
/// Timestamp of the previous frame, used to derive delta-time. `None` before the first frame.
|
||||
last_frame: Option<Instant>,
|
||||
/// Handles onto the background network connection: the handshake outcome, the chunk-subscription sender, and the chunk-delivery receiver. `None` before the connection is started.
|
||||
link: Option<net::ClientLink>,
|
||||
/// Whether the handshake has completed successfully. Gates chunk subscription until the connection is usable.
|
||||
connected: bool,
|
||||
/// The chunk position the camera last subscribed around, so a new subscription is sent only when the center chunk changes.
|
||||
last_center: Option<shared::world::ChunkPos>,
|
||||
/// Streams chunk meshes in and out around the camera. `None` until the renderer is initialised on resume.
|
||||
chunks: Option<chunks::ChunkManager>,
|
||||
/// Whether the debug statistics panel is being emitted. Collection is unconditional; only emission is gated on this.
|
||||
stats_overlay: bool,
|
||||
/// Accumulates per-frame delta times and closes the measurement window the panel reports over.
|
||||
frames: FrameAccumulator,
|
||||
/// Owns the host inspection handle, refreshed on the panel's cadence rather than per frame.
|
||||
host: HostMonitor,
|
||||
/// Address dialled at startup, retained so the session identity can classify the server kind.
|
||||
server_addr: std::net::SocketAddr,
|
||||
/// Session identity, assembled once the handshake reply arrives.
|
||||
identity: Option<ServerIdentity>,
|
||||
/// The server's most recent report from the authority stream, retained between windows since it arrives on its own cadence.
|
||||
server_stats: Option<ServerStats>,
|
||||
/// Camera position at the previous frame, used to derive travelled distance.
|
||||
last_position: Vec3,
|
||||
/// Delta time of the previous frame, in seconds, used to derive speed from that distance.
|
||||
last_dt: f32,
|
||||
}
|
||||
|
||||
impl Default for App {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
renderer: None,
|
||||
window: None,
|
||||
// Start above and behind the origin chunk, looking toward -Z and angled downward.
|
||||
camera: Camera::new(
|
||||
Vec3::new(16.0, 40.0, 60.0),
|
||||
-std::f32::consts::FRAC_PI_2,
|
||||
-0.5,
|
||||
),
|
||||
input: InputState::default(),
|
||||
debug: debug::DebugControls::default(),
|
||||
last_frame: None,
|
||||
link: None,
|
||||
connected: false,
|
||||
last_center: None,
|
||||
chunks: None,
|
||||
stats_overlay: false,
|
||||
frames: FrameAccumulator::new(Instant::now()),
|
||||
host: HostMonitor::new(),
|
||||
server_addr: std::net::SocketAddr::from((
|
||||
std::net::Ipv4Addr::LOCALHOST,
|
||||
net::DEFAULT_PORT,
|
||||
)),
|
||||
identity: None,
|
||||
server_stats: None,
|
||||
last_position: Vec3::ZERO,
|
||||
last_dt: 0.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl App {
|
||||
/// Applies a debug action produced by [`debug::DebugControls`].
|
||||
///
|
||||
/// Actions targeting the renderer are dropped while it is uninitialised, which is the window between application start and the first `resumed` call.
|
||||
fn apply_debug_action(&mut self, action: debug::DebugAction) {
|
||||
match action {
|
||||
debug::DebugAction::SetRenderMode(mode) => {
|
||||
if let Some(renderer) = self.renderer.as_mut() {
|
||||
renderer.set_render_mode(mode);
|
||||
info!(?mode, "render mode toggled");
|
||||
}
|
||||
}
|
||||
debug::DebugAction::SetStatsOverlay(enabled) => {
|
||||
self.stats_overlay = enabled;
|
||||
info!(enabled, "statistics overlay toggled");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl App {
|
||||
/// Advances one frame: resolves the handshake, samples timing, updates the camera and streamed chunks, emits statistics, and submits the draw.
|
||||
fn redraw(&mut self, event_loop: &ActiveEventLoop) {
|
||||
// Non-blocking check for the handshake outcome. The link is retained after success so its chunk channels can be used; only a failure discards it.
|
||||
if !self.connected {
|
||||
let outcome = self
|
||||
.link
|
||||
.as_ref()
|
||||
.and_then(|link| link.handshake.try_recv().ok());
|
||||
match outcome {
|
||||
Some(Ok(ack)) => {
|
||||
info!(
|
||||
protocol_version = ack.protocol_version,
|
||||
"handshake complete"
|
||||
);
|
||||
// The ack's build string, protocol version, and tick-rate hint are all reported by the panel, so the reply is retained rather than logged and dropped.
|
||||
self.identity = Some(ServerIdentity {
|
||||
kind: ServerKind::dedicated(self.server_addr),
|
||||
address: self.server_addr,
|
||||
server_build: ack.server_build,
|
||||
protocol_version: ack.protocol_version,
|
||||
tick_rate_hint: ack.tick_rate_hint,
|
||||
});
|
||||
self.connected = true;
|
||||
}
|
||||
Some(Err(reason)) => {
|
||||
warn!("handshake failed: {reason}");
|
||||
self.link = None;
|
||||
}
|
||||
// No outcome yet (empty), or the network thread ended (disconnected).
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
|
||||
// Derive delta-time from the previous frame so movement is framerate-independent. The first frame has no predecessor and therefore advances by zero seconds.
|
||||
let now = Instant::now();
|
||||
let dt = self
|
||||
.last_frame
|
||||
.map_or(0.0, |prev| now.duration_since(prev).as_secs_f32());
|
||||
self.last_frame = Some(now);
|
||||
// Collection is unconditional: gating it on the toggle would leave the first window after enabling the panel empty or wrong.
|
||||
self.frames.record(dt);
|
||||
let travelled = self.camera.position - self.last_position;
|
||||
self.last_position = self.camera.position;
|
||||
self.last_dt = dt;
|
||||
|
||||
// Drain the authority stream so the latest server report is the one the next window sees.
|
||||
if let Some(link) = self.link.as_mut() {
|
||||
while let Ok(AuthorityMessage::ServerStats(server_stats)) = link.authority.try_recv() {
|
||||
self.server_stats = Some(server_stats);
|
||||
}
|
||||
}
|
||||
|
||||
self.camera.update(&self.input, dt);
|
||||
// The accumulated motion has been applied; clear it so it is not counted twice.
|
||||
self.input.mouse_delta = (0.0, 0.0);
|
||||
|
||||
// Reconcile streamed chunks toward the chunk the camera now occupies. `from_world` floors via `div_euclid`, so negative coordinates map to the correct chunk.
|
||||
let pos = self.camera.position;
|
||||
let center = shared::world::ChunkPos::from_world(
|
||||
f64::from(pos.x),
|
||||
f64::from(pos.y),
|
||||
f64::from(pos.z),
|
||||
);
|
||||
|
||||
// Subscribe to the region around the camera whenever the center chunk changes, so the server streams the matching set. The client subscribes with its own load radius so the server's resident set aligns with what the client keeps.
|
||||
if self.connected && self.last_center != Some(center) {
|
||||
if let Some(link) = self.link.as_ref() {
|
||||
let radius = u16::try_from(chunks::LOAD_RADIUS).unwrap_or(u16::MAX);
|
||||
link.subscribe
|
||||
.send(shared::protocol::chunk::ChunkSubscribe { center, radius });
|
||||
}
|
||||
self.last_center = Some(center);
|
||||
}
|
||||
|
||||
// Apply queued server deliveries and reconcile the resident set against the camera.
|
||||
if let (Some(chunks), Some(link), Some(renderer)) = (
|
||||
self.chunks.as_mut(),
|
||||
self.link.as_mut(),
|
||||
self.renderer.as_mut(),
|
||||
) {
|
||||
chunks.update(center, &mut link.chunks, renderer);
|
||||
}
|
||||
|
||||
if let Some(frame) = self.frames.take_window(now) {
|
||||
self.report_statistics(frame, center, travelled);
|
||||
}
|
||||
|
||||
let frame = renderer::FrameParams {
|
||||
view: self.camera.view_matrix(),
|
||||
camera_position: pos,
|
||||
fog_end_horizontal: chunks::LOAD_DISTANCE,
|
||||
fog_end_vertical: chunks::LOAD_DISTANCE_VERTICAL,
|
||||
};
|
||||
if let Some(Err(e)) = self.renderer.as_mut().map(|r| r.draw_frame(frame)) {
|
||||
error!("Failed to draw frame: {e}");
|
||||
event_loop.exit();
|
||||
}
|
||||
|
||||
if let Some(window) = self.window.as_ref() {
|
||||
window.request_redraw();
|
||||
}
|
||||
}
|
||||
|
||||
/// Composes one window's statistics from every source and emits the formatted panel.
|
||||
///
|
||||
/// Called on the panel's cadence rather than per frame, so the host refresh and the formatting cost are paid once per window. Emission is skipped while the overlay is disabled, but the window is still closed by the caller so the figures stay current.
|
||||
fn report_statistics(
|
||||
&mut self,
|
||||
frame: stats::FrameStats,
|
||||
center: shared::world::ChunkPos,
|
||||
travelled: Vec3,
|
||||
) {
|
||||
if !self.stats_overlay {
|
||||
return;
|
||||
}
|
||||
|
||||
let usage = self.host.usage();
|
||||
let camera = stats::camera_stats(
|
||||
self.camera.position,
|
||||
self.camera.forward(),
|
||||
self.camera.yaw,
|
||||
self.camera.pitch,
|
||||
travelled,
|
||||
self.last_dt,
|
||||
);
|
||||
|
||||
let panel = stats::format_panel(&Snapshot {
|
||||
frame,
|
||||
camera,
|
||||
chunks: self.chunks.as_ref().map(|chunks| chunks.stats(center)),
|
||||
render: self.renderer.as_ref().and_then(renderer::Renderer::stats),
|
||||
gpu: self.renderer.as_ref().map(renderer::Renderer::gpu_info),
|
||||
memory: self.renderer.as_ref().map(renderer::Renderer::memory_usage),
|
||||
net: self.link.as_ref().map(net::ClientLink::stats),
|
||||
server: self.server_stats,
|
||||
identity: self.identity.as_ref(),
|
||||
host: self.host.info(),
|
||||
usage,
|
||||
});
|
||||
info!("\n{panel}");
|
||||
}
|
||||
}
|
||||
|
||||
impl ApplicationHandler for App {
|
||||
fn resumed(&mut self, event_loop: &ActiveEventLoop) {
|
||||
let attributes = Window::default_attributes().with_title("Synvael");
|
||||
|
||||
let window = match event_loop.create_window(attributes) {
|
||||
Ok(w) => w,
|
||||
Err(e) => {
|
||||
error!("Failed to create window: {e}");
|
||||
event_loop.exit();
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let display_handle = match event_loop.display_handle() {
|
||||
Ok(h) => h.as_raw(),
|
||||
Err(e) => {
|
||||
error!("Failed to get display handle: {e}");
|
||||
event_loop.exit();
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let window_handle = match window.window_handle() {
|
||||
Ok(h) => h.as_raw(),
|
||||
Err(e) => {
|
||||
error!("Failed to get window handle: {e}");
|
||||
event_loop.exit();
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let required_extensions = match ash_window::enumerate_required_extensions(display_handle) {
|
||||
Ok(exts) => exts,
|
||||
Err(e) => {
|
||||
error!("Failed to enumerate required extensions: {e}");
|
||||
event_loop.exit();
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let size = window.inner_size();
|
||||
let renderer = match renderer::Renderer::new(
|
||||
display_handle,
|
||||
window_handle,
|
||||
size.width,
|
||||
size.height,
|
||||
required_extensions,
|
||||
) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
error!("Failed to initialize Vulkan renderer: {e}");
|
||||
event_loop.exit();
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// Confine and hide the pointer so mouse motion drives the camera rather than moving a visible cursor. `Locked` is preferred; some platforms only support `Confined`.
|
||||
if let Err(e) = window
|
||||
.set_cursor_grab(CursorGrabMode::Locked)
|
||||
.or_else(|_| window.set_cursor_grab(CursorGrabMode::Confined))
|
||||
{
|
||||
warn!("Failed to grab cursor: {e}");
|
||||
}
|
||||
window.set_cursor_visible(false);
|
||||
|
||||
self.window = Some(window);
|
||||
self.renderer = Some(renderer);
|
||||
|
||||
// The client renders only server-streamed terrain and no longer generates chunks locally.
|
||||
// TODO: offline/singleplayer via an in-process server would reintroduce a local world source here.
|
||||
self.chunks = Some(chunks::ChunkManager::new());
|
||||
|
||||
// Kick off a background connect + handshake to the local server.
|
||||
let hello = shared::protocol::ClientHello {
|
||||
protocol_version: shared::protocol::PROTOCOL_VERSION,
|
||||
client_build: env!("CARGO_PKG_VERSION").to_owned(),
|
||||
player_identity: shared::protocol::PlayerIdentity {
|
||||
display_name: "Player".to_owned(),
|
||||
},
|
||||
installed_packs: Vec::new(),
|
||||
requested_features: shared::protocol::FeatureFlags(0),
|
||||
};
|
||||
info!("Connecting to server at {}", self.server_addr);
|
||||
self.link = Some(net::connect_in_background(self.server_addr, hello));
|
||||
}
|
||||
|
||||
fn window_event(&mut self, event_loop: &ActiveEventLoop, _id: WindowId, event: WindowEvent) {
|
||||
match event {
|
||||
WindowEvent::CloseRequested => {
|
||||
event_loop.exit();
|
||||
}
|
||||
WindowEvent::Resized(size) => {
|
||||
// Rebuild the swapchain to match the new surface size. Without this the swapchain keeps its initial extent and the compositor stretches the fixed-size image to the window, distorting the aspect ratio.
|
||||
if let Some(renderer) = self.renderer.as_mut()
|
||||
&& let Err(e) = renderer.recreate_swapchain(size.width, size.height)
|
||||
{
|
||||
error!("Failed to recreate swapchain on resize: {e}");
|
||||
event_loop.exit();
|
||||
}
|
||||
}
|
||||
WindowEvent::KeyboardInput { event, .. } => {
|
||||
let pressed = event.state == ElementState::Pressed;
|
||||
if let PhysicalKey::Code(code) = event.physical_key {
|
||||
// Debug chords are resolved first and on their own seam, so debug bindings can grow without entangling the gameplay bindings below.
|
||||
if let Some(action) = self.debug.handle_key(code, pressed) {
|
||||
self.apply_debug_action(action);
|
||||
}
|
||||
|
||||
match code {
|
||||
KeyCode::KeyW => self.input.forward = pressed,
|
||||
KeyCode::KeyS => self.input.backward = pressed,
|
||||
KeyCode::KeyA => self.input.left = pressed,
|
||||
KeyCode::KeyD => self.input.right = pressed,
|
||||
KeyCode::Space => self.input.up = pressed,
|
||||
KeyCode::ShiftLeft => self.input.down = pressed,
|
||||
KeyCode::Escape => event_loop.exit(),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
WindowEvent::RedrawRequested => self.redraw(event_loop),
|
||||
_ => (),
|
||||
}
|
||||
}
|
||||
|
||||
fn device_event(
|
||||
&mut self,
|
||||
_event_loop: &ActiveEventLoop,
|
||||
_device_id: DeviceId,
|
||||
event: DeviceEvent,
|
||||
) {
|
||||
// Raw mouse motion is used for look control; it is unaffected by pointer acceleration or the desktop cursor position, which absolute window coordinates would not guarantee.
|
||||
if let DeviceEvent::MouseMotion { delta } = event {
|
||||
self.input.mouse_delta.0 += delta.0;
|
||||
self.input.mouse_delta.1 += delta.1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn main() -> Result<()> {
|
||||
// An unset `RUST_LOG` leaves `from_default_env` with no directives, which discards every event including the statistics panel. A fallback keeps the client audible out of the box while `RUST_LOG` still overrides it.
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(
|
||||
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")),
|
||||
)
|
||||
.init();
|
||||
info!("Starting Synvael client");
|
||||
|
||||
let event_loop = EventLoop::new().context("Failed to create event loop")?;
|
||||
|
||||
event_loop.set_control_flow(ControlFlow::Poll);
|
||||
|
||||
let mut app = App::default();
|
||||
event_loop
|
||||
.run_app(&mut app)
|
||||
.context("Failed to run event loop")?;
|
||||
Ok(())
|
||||
}
|
||||
151
crates/client/src/mesh_pool.rs
Normal file
151
crates/client/src/mesh_pool.rs
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
//! Background worker pool that meshes chunks off the winit thread.
|
||||
|
||||
use std::num::NonZero;
|
||||
use std::sync::Arc;
|
||||
use std::thread::JoinHandle;
|
||||
|
||||
use crossbeam_channel::{Receiver, Sender};
|
||||
use renderer::meshing::{Neighbors, generate_mesh};
|
||||
use renderer::vertex::Vertex;
|
||||
use shared::world::{Chunk, ChunkPos};
|
||||
|
||||
/// Monotonic staleness token stamped on every dispatched [`MeshJob`].
|
||||
///
|
||||
/// Between dispatching a job for a position and the worker returning it, that position may have been evicted or re-dispatched with fresher neighbours (a neighbour loaded or dropped). A returned mesh is applied only when its generation still matches the latest generation recorded for the position; older generations are superseded and discarded.
|
||||
#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
|
||||
pub(crate) struct JobGen(u64);
|
||||
|
||||
impl JobGen {
|
||||
/// The generation of the first job ever dispatched.
|
||||
pub(crate) const FIRST: Self = Self(0);
|
||||
|
||||
/// Returns the next generation after `self`.
|
||||
///
|
||||
/// Wraps on overflow rather than panicking; wrap-around requires 2^64 dispatches in one session, at which point a collision would additionally require the wrapped-to job to still be outstanding, which is unreachable in practice.
|
||||
pub(crate) fn next(self) -> Self {
|
||||
Self(self.0.wrapping_add(1))
|
||||
}
|
||||
}
|
||||
|
||||
/// A unit of meshing work handed to a worker: an owned snapshot so the worker borrows nothing from the manager.
|
||||
///
|
||||
/// The chunk and its neighbours are carried as [`Arc`] handles so dispatch is a cheap refcount bump rather than a copy of the 64 KiB voxel volume. Meshing is neighbour-dependent (boundary faces are culled against adjacent chunks), so the six face-adjacent neighbours are snapshotted at dispatch time; a `None` entry means that neighbour is not resident and the boundary is treated as exposed.
|
||||
pub(crate) struct MeshJob {
|
||||
/// Chunk-space position of the chunk to mesh.
|
||||
pub(crate) pos: ChunkPos,
|
||||
/// Staleness token identifying this dispatch; echoed back on the result.
|
||||
pub(crate) generation: JobGen,
|
||||
/// The chunk to mesh.
|
||||
pub(crate) chunk: Arc<Chunk>,
|
||||
/// The six face-adjacent neighbours, ordered `[+X, -X, +Y, -Y, +Z, -Z]` to match `chunks::NEIGHBOR_OFFSETS`. `None` marks an absent neighbour.
|
||||
pub(crate) neighbors: [Option<Arc<Chunk>>; 6],
|
||||
}
|
||||
|
||||
/// A finished mesh returned from a worker to the main thread for upload.
|
||||
pub(crate) struct MeshResult {
|
||||
/// Chunk-space position the mesh belongs to.
|
||||
pub(crate) pos: ChunkPos,
|
||||
/// Generated vertices; empty when the chunk meshes to no geometry.
|
||||
pub(crate) vertices: Vec<Vertex>,
|
||||
/// Generated triangle indices; empty when the chunk meshes to no geometry.
|
||||
pub(crate) indices: Vec<u32>,
|
||||
/// The generation stamped on the originating [`MeshJob`], used to discard superseded results.
|
||||
pub(crate) generation: JobGen,
|
||||
}
|
||||
|
||||
/// A pool of worker threads that mesh chunks and return CPU geometry.
|
||||
pub(crate) struct MeshPool {
|
||||
/// Sending end of the job queue; the main thread pushes [`MeshJob`]s.
|
||||
job_tx: Sender<MeshJob>,
|
||||
/// Receiving end of the result queue; the main thread drains finished meshes.
|
||||
result_rx: Receiver<MeshResult>,
|
||||
/// Handles to the worker threads, retained for a future graceful-stop path that drops `job_tx` and joins them; the process currently relies on OS teardown at exit. Read in the meantime only for its length, by [`MeshPool::worker_count`].
|
||||
workers: Vec<JoinHandle<()>>,
|
||||
}
|
||||
|
||||
impl MeshPool {
|
||||
/// Spawns the worker pool, sizing it to leave one logical core for the main thread.
|
||||
pub(crate) fn new() -> Self {
|
||||
let (job_tx, job_rx) = crossbeam_channel::unbounded::<MeshJob>();
|
||||
let (result_tx, result_rx) = crossbeam_channel::unbounded::<MeshResult>();
|
||||
|
||||
// One worker per logical core, less one to keep the winit thread responsive, but never fewer than one.
|
||||
let cores = std::thread::available_parallelism().map_or(4, NonZero::get);
|
||||
let worker_count = cores.saturating_sub(1).max(1);
|
||||
|
||||
let workers = (0..worker_count)
|
||||
.map(|_| {
|
||||
// Each worker owns its own clone of the shared job queue and of the sender back into the result queue.
|
||||
let job_rx = job_rx.clone();
|
||||
let result_tx = result_tx.clone();
|
||||
|
||||
std::thread::spawn(move || {
|
||||
// Block until a job arrives; a blocking recv is fine off the main thread.
|
||||
while let Ok(job) = job_rx.recv() {
|
||||
let (vertices, indices) = mesh_job(&job);
|
||||
let result = MeshResult {
|
||||
pos: job.pos,
|
||||
vertices,
|
||||
indices,
|
||||
generation: job.generation,
|
||||
};
|
||||
// A send error means the main thread has gone away; the worker winds down.
|
||||
if result_tx.send(result).is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Drop the template ends left over after cloning so the channels close once the real holders are gone: workers observe job-channel shutdown, and the main thread observes result-channel shutdown.
|
||||
drop(job_rx);
|
||||
drop(result_tx);
|
||||
|
||||
Self {
|
||||
job_tx,
|
||||
result_rx,
|
||||
workers,
|
||||
}
|
||||
}
|
||||
|
||||
/// Enqueues a meshing job for the pool.
|
||||
///
|
||||
/// Returns the number of worker threads the pool was spawned with.
|
||||
pub(crate) fn worker_count(&self) -> usize {
|
||||
self.workers.len()
|
||||
}
|
||||
|
||||
/// A send error (the workers have shut down) is ignored: there is nothing useful to do with the job, and shutdown only happens at process teardown.
|
||||
pub(crate) fn dispatch(&self, job: MeshJob) {
|
||||
let _ = self.job_tx.send(job);
|
||||
}
|
||||
|
||||
/// Returns the next finished mesh without blocking, or `None` when none is ready.
|
||||
pub(crate) fn poll(&self) -> Option<MeshResult> {
|
||||
self.result_rx.try_recv().ok()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for MeshPool {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// Reconstructs a borrowed [`Neighbors`] view from a job's owned neighbour [`Arc`]s and meshes the chunk.
|
||||
///
|
||||
/// The [`Neighbors`] view borrows `&Chunk` out of the job's `Arc`s, so it is built and consumed here in one scope while those `Arc`s are still alive.
|
||||
fn mesh_job(job: &MeshJob) -> (Vec<Vertex>, Vec<u32>) {
|
||||
let neighbors = Neighbors {
|
||||
pos_x: job.neighbors[0].as_deref(),
|
||||
neg_x: job.neighbors[1].as_deref(),
|
||||
pos_y: job.neighbors[2].as_deref(),
|
||||
neg_y: job.neighbors[3].as_deref(),
|
||||
pos_z: job.neighbors[4].as_deref(),
|
||||
neg_z: job.neighbors[5].as_deref(),
|
||||
};
|
||||
generate_mesh(&job.chunk, &neighbors)
|
||||
}
|
||||
605
crates/client/src/stats.rs
Normal file
605
crates/client/src/stats.rs
Normal file
|
|
@ -0,0 +1,605 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
//! Collection and formatting of the debug statistics panel.
|
||||
|
||||
use std::fmt::Write as _;
|
||||
use std::net::SocketAddr;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use glam::{IVec3, Vec3};
|
||||
use renderer::{GpuInfo, MemoryUsage, RenderStats};
|
||||
use shared::protocol::authority::ServerStats;
|
||||
use shared::session::ServerKind;
|
||||
use shared::world::{CHUNK_SIZE, ChunkPos};
|
||||
use sysinfo::{Pid, ProcessRefreshKind, ProcessesToUpdate, RefreshKind, System};
|
||||
|
||||
use crate::chunks::ChunkStats;
|
||||
|
||||
/// Wall-clock cadence at which a measurement window closes and a panel is emitted.
|
||||
pub const STATS_INTERVAL: Duration = Duration::from_secs(1);
|
||||
|
||||
/// Bytes in one mebibyte, the unit memory figures are reported in.
|
||||
const BYTES_PER_MIB: f32 = 1024.0 * 1024.0;
|
||||
|
||||
/// Converts a byte count to mebibytes for display.
|
||||
///
|
||||
/// The precision loss is intentional: the result is a display figure rounded to one decimal place, not an accounting quantity.
|
||||
#[must_use]
|
||||
#[expect(
|
||||
clippy::cast_precision_loss,
|
||||
reason = "the result is a display figure, not an exact byte count"
|
||||
)]
|
||||
fn mib(bytes: u64) -> f32 {
|
||||
bytes as f32 / BYTES_PER_MIB
|
||||
}
|
||||
|
||||
/// Frame timing aggregated over one measurement window.
|
||||
#[derive(Copy, Clone, Debug, PartialEq)]
|
||||
pub struct FrameStats {
|
||||
/// Frames drawn in the window, expressed per second.
|
||||
pub average_fps: f32,
|
||||
/// Mean time between frames in the window, in milliseconds.
|
||||
pub mean_frame_ms: f32,
|
||||
/// Shortest time between frames in the window, in milliseconds.
|
||||
pub min_frame_ms: f32,
|
||||
/// Longest time between frames in the window, in milliseconds. The figure that exposes stutter a mean conceals.
|
||||
pub max_frame_ms: f32,
|
||||
/// Frames counted in the window.
|
||||
pub frames: u32,
|
||||
}
|
||||
|
||||
/// Accumulates per-frame delta times and closes a measurement window on a fixed cadence.
|
||||
#[derive(Debug)]
|
||||
pub struct FrameAccumulator {
|
||||
/// Instant the current window opened; the window closes once [`STATS_INTERVAL`] has elapsed from here.
|
||||
window_start: Instant,
|
||||
/// Frames recorded in the current window.
|
||||
frames: u32,
|
||||
/// Summed delta time of every frame in the current window, in seconds.
|
||||
total: f32,
|
||||
/// Shortest delta time in the current window, in seconds.
|
||||
min: f32,
|
||||
/// Longest delta time in the current window, in seconds.
|
||||
max: f32,
|
||||
}
|
||||
|
||||
impl FrameAccumulator {
|
||||
/// Opens the first measurement window at `now`.
|
||||
#[must_use]
|
||||
pub fn new(now: Instant) -> Self {
|
||||
Self {
|
||||
window_start: now,
|
||||
frames: 0,
|
||||
total: 0.0,
|
||||
min: f32::INFINITY,
|
||||
max: 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Records one frame whose delta time was `dt` seconds.
|
||||
pub fn record(&mut self, dt: f32) {
|
||||
self.frames = self.frames.saturating_add(1);
|
||||
self.total += dt;
|
||||
self.min = self.min.min(dt);
|
||||
self.max = self.max.max(dt);
|
||||
}
|
||||
|
||||
/// Closes the window and returns its summary once [`STATS_INTERVAL`] has elapsed since it opened, otherwise returns [`None`].
|
||||
///
|
||||
/// On close the accumulators reset and a fresh window opens at `now`, so windows tile the timeline without gaps or overlap.
|
||||
pub fn take_window(&mut self, now: Instant) -> Option<FrameStats> {
|
||||
let elapsed = now.saturating_duration_since(self.window_start);
|
||||
if elapsed < STATS_INTERVAL {
|
||||
return None;
|
||||
}
|
||||
|
||||
let stats = summarise_frames(self.frames, self.total, self.min, self.max, elapsed);
|
||||
self.window_start = now;
|
||||
self.frames = 0;
|
||||
self.total = 0.0;
|
||||
self.min = f32::INFINITY;
|
||||
self.max = 0.0;
|
||||
Some(stats)
|
||||
}
|
||||
}
|
||||
|
||||
/// Derives a frame-timing summary from a window's raw accumulators.
|
||||
///
|
||||
/// Split out from [`FrameAccumulator::take_window`] so the arithmetic is exercisable without driving a clock. A window containing no frames reports zeroes throughout rather than dividing by zero, and its minimum is reported as zero rather than the sentinel infinity the accumulator starts from.
|
||||
fn summarise_frames(frames: u32, total: f32, min: f32, max: f32, elapsed: Duration) -> FrameStats {
|
||||
if frames == 0 {
|
||||
return FrameStats {
|
||||
average_fps: 0.0,
|
||||
mean_frame_ms: 0.0,
|
||||
min_frame_ms: 0.0,
|
||||
max_frame_ms: 0.0,
|
||||
frames: 0,
|
||||
};
|
||||
}
|
||||
|
||||
// Frame counts within a one-second window stay far inside f32's exact-integer range.
|
||||
#[expect(
|
||||
clippy::cast_precision_loss,
|
||||
reason = "frame counts per window stay well within f32's exact-integer range"
|
||||
)]
|
||||
let count = frames as f32;
|
||||
let seconds = elapsed.as_secs_f32();
|
||||
|
||||
FrameStats {
|
||||
// The rate is frames over wall clock, not over summed delta time: the two differ whenever a frame's measured delta excludes time the loop spent elsewhere, and wall clock is the honest denominator.
|
||||
average_fps: if seconds > 0.0 { count / seconds } else { 0.0 },
|
||||
mean_frame_ms: total / count * 1000.0,
|
||||
min_frame_ms: min * 1000.0,
|
||||
max_frame_ms: max * 1000.0,
|
||||
frames,
|
||||
}
|
||||
}
|
||||
|
||||
/// Where the camera is and where it is pointing, in every frame of reference worth reading at once.
|
||||
#[derive(Copy, Clone, Debug, PartialEq)]
|
||||
pub struct CameraStats {
|
||||
/// Continuous world position, in blocks.
|
||||
pub position: Vec3,
|
||||
/// The block the camera occupies, floored from `position`.
|
||||
pub block: IVec3,
|
||||
/// The chunk containing that block.
|
||||
pub chunk: ChunkPos,
|
||||
/// Position within the containing chunk, in the range `0..CHUNK_SIZE` on each axis.
|
||||
pub local: IVec3,
|
||||
/// Cardinal direction the camera faces, from the dominant horizontal component of its forward vector.
|
||||
pub facing: &'static str,
|
||||
/// Signed axis matching `facing`, for readers who think in axes rather than compass points.
|
||||
pub facing_axis: &'static str,
|
||||
/// Camera yaw, in degrees.
|
||||
pub yaw_degrees: f32,
|
||||
/// Camera pitch, in degrees.
|
||||
pub pitch_degrees: f32,
|
||||
/// Magnitude of the camera's movement over the last frame, in blocks per second.
|
||||
pub speed: f32,
|
||||
}
|
||||
|
||||
/// Maps a forward vector to the cardinal direction and signed axis it points along.
|
||||
///
|
||||
/// Only the horizontal components are considered; pitch does not change which way the camera faces on the compass.
|
||||
#[must_use]
|
||||
fn facing_for(forward: Vec3) -> (&'static str, &'static str) {
|
||||
if forward.x.abs() > forward.z.abs() {
|
||||
if forward.x > 0.0 {
|
||||
("east", "+X")
|
||||
} else {
|
||||
("west", "-X")
|
||||
}
|
||||
} else if forward.z > 0.0 {
|
||||
("south", "+Z")
|
||||
} else {
|
||||
("north", "-Z")
|
||||
}
|
||||
}
|
||||
|
||||
/// Derives the camera figures from a position, orientation, and the distance covered since the previous frame.
|
||||
///
|
||||
/// `dt` is the previous frame's delta time in seconds; a zero or negative value yields a reported speed of zero rather than a division by zero.
|
||||
#[must_use]
|
||||
pub fn camera_stats(
|
||||
position: Vec3,
|
||||
forward: Vec3,
|
||||
yaw: f32,
|
||||
pitch: f32,
|
||||
travelled: Vec3,
|
||||
dt: f32,
|
||||
) -> CameraStats {
|
||||
// Flooring rather than truncating: a position of -0.5 lies in block -1, and truncation would place it in block 0.
|
||||
let block = position.floor().as_ivec3();
|
||||
let chunk = ChunkPos::from_world(
|
||||
f64::from(position.x),
|
||||
f64::from(position.y),
|
||||
f64::from(position.z),
|
||||
);
|
||||
// The chunk edge is a compile-time constant of 32, so the narrowing cast is exact.
|
||||
#[expect(
|
||||
clippy::cast_possible_truncation,
|
||||
clippy::cast_possible_wrap,
|
||||
reason = "CHUNK_SIZE is a small compile-time constant"
|
||||
)]
|
||||
let size = CHUNK_SIZE as i32;
|
||||
let local = IVec3::new(
|
||||
block.x.rem_euclid(size),
|
||||
block.y.rem_euclid(size),
|
||||
block.z.rem_euclid(size),
|
||||
);
|
||||
let (facing, facing_axis) = facing_for(forward);
|
||||
|
||||
CameraStats {
|
||||
position,
|
||||
block,
|
||||
chunk,
|
||||
local,
|
||||
facing,
|
||||
facing_axis,
|
||||
yaw_degrees: yaw.to_degrees(),
|
||||
pitch_degrees: pitch.to_degrees(),
|
||||
speed: if dt > 0.0 {
|
||||
travelled.length() / dt
|
||||
} else {
|
||||
0.0
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Facts about the machine and process that do not change while the client runs.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct HostInfo {
|
||||
/// Brand string of the first CPU the system reports.
|
||||
pub cpu_brand: String,
|
||||
/// Logical cores visible to the process.
|
||||
pub logical_cores: usize,
|
||||
/// Operating system name and version.
|
||||
pub os: String,
|
||||
/// Kernel version string.
|
||||
pub kernel: String,
|
||||
/// Version of this client binary, from the crate manifest.
|
||||
pub client_build: &'static str,
|
||||
}
|
||||
|
||||
/// Host and process figures that change from window to window.
|
||||
#[derive(Copy, Clone, Debug, PartialEq)]
|
||||
pub struct HostUsage {
|
||||
/// Share of one core's worth of time this process consumed, in percent. Exceeds 100 on a process using more than one core.
|
||||
pub process_cpu_percent: f32,
|
||||
/// Resident set size of this process, in bytes.
|
||||
pub process_memory_bytes: u64,
|
||||
/// Virtual address space reserved by this process, in bytes.
|
||||
pub process_virtual_bytes: u64,
|
||||
/// Total physical memory installed, in bytes.
|
||||
pub system_total_bytes: u64,
|
||||
/// Physical memory available for allocation, in bytes.
|
||||
pub system_available_bytes: u64,
|
||||
/// Current clock of the first CPU the system reports, in MHz.
|
||||
pub cpu_frequency_mhz: u64,
|
||||
}
|
||||
|
||||
/// Owns the `sysinfo` handle and reads host figures on the panel's cadence.
|
||||
///
|
||||
/// Construction is expensive and the per-window refresh is deliberately narrow: only this process's entry and the CPU are refreshed, never the full system enumeration. The handle is therefore built once and kept for the lifetime of the client.
|
||||
pub struct HostMonitor {
|
||||
/// The `sysinfo` view of the machine, refreshed selectively.
|
||||
system: System,
|
||||
/// Identifier of this process, resolved once at construction.
|
||||
pid: Pid,
|
||||
/// Immutable facts read once at construction.
|
||||
info: HostInfo,
|
||||
}
|
||||
|
||||
impl HostMonitor {
|
||||
/// Builds the monitor, reading the immutable host facts once.
|
||||
#[must_use]
|
||||
pub fn new() -> Self {
|
||||
let mut system = System::new_with_specifics(
|
||||
RefreshKind::nothing()
|
||||
.with_cpu(sysinfo::CpuRefreshKind::everything())
|
||||
.with_memory(sysinfo::MemoryRefreshKind::everything()),
|
||||
);
|
||||
system.refresh_processes(ProcessesToUpdate::All, true);
|
||||
|
||||
let info = HostInfo {
|
||||
cpu_brand: system
|
||||
.cpus()
|
||||
.first()
|
||||
.map_or_else(|| "unknown".to_owned(), |cpu| cpu.brand().trim().to_owned()),
|
||||
logical_cores: system.cpus().len(),
|
||||
os: System::long_os_version().unwrap_or_else(|| "unknown".to_owned()),
|
||||
kernel: System::kernel_version().unwrap_or_else(|| "unknown".to_owned()),
|
||||
client_build: env!("CARGO_PKG_VERSION"),
|
||||
};
|
||||
|
||||
Self {
|
||||
system,
|
||||
pid: sysinfo::get_current_pid().unwrap_or_else(|_| Pid::from(0)),
|
||||
info,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the immutable host facts.
|
||||
#[must_use]
|
||||
pub const fn info(&self) -> &HostInfo {
|
||||
&self.info
|
||||
}
|
||||
|
||||
/// Refreshes and returns the changing host and process figures.
|
||||
pub fn usage(&mut self) -> HostUsage {
|
||||
self.system.refresh_cpu_usage();
|
||||
self.system.refresh_memory();
|
||||
self.system.refresh_processes_specifics(
|
||||
ProcessesToUpdate::Some(&[self.pid]),
|
||||
true,
|
||||
ProcessRefreshKind::nothing().with_cpu().with_memory(),
|
||||
);
|
||||
|
||||
let process = self.system.process(self.pid);
|
||||
HostUsage {
|
||||
process_cpu_percent: process.map_or(0.0, sysinfo::Process::cpu_usage),
|
||||
process_memory_bytes: process.map_or(0, sysinfo::Process::memory),
|
||||
process_virtual_bytes: process.map_or(0, sysinfo::Process::virtual_memory),
|
||||
system_total_bytes: self.system.total_memory(),
|
||||
system_available_bytes: self.system.available_memory(),
|
||||
cpu_frequency_mhz: self
|
||||
.system
|
||||
.cpus()
|
||||
.first()
|
||||
.map_or(0, sysinfo::Cpu::frequency),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for HostMonitor {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// Who the client is playing against, assembled from the address dialled and the handshake reply.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct ServerIdentity {
|
||||
/// Whether the server is integrated, local, or remote. Decided client-side; see [`ServerKind`].
|
||||
pub kind: ServerKind,
|
||||
/// The address the client dialled.
|
||||
pub address: SocketAddr,
|
||||
/// Build string the server reported in the handshake.
|
||||
pub server_build: String,
|
||||
/// Protocol version the two peers agreed on.
|
||||
pub protocol_version: u32,
|
||||
/// Nominal tick rate the server advertised, in Hz. Compare against the measured rate in [`ServerStats`].
|
||||
pub tick_rate_hint: u16,
|
||||
}
|
||||
|
||||
/// One window's worth of statistics from every source, ready to format.
|
||||
///
|
||||
/// Fields are [`Option`] wherever the source may not exist yet: before the renderer is initialised, before the handshake completes, or before the server has pushed its first report.
|
||||
pub struct Snapshot<'a> {
|
||||
/// Frame timing measured by the client over the window.
|
||||
pub frame: FrameStats,
|
||||
/// Camera position and orientation at the end of the window.
|
||||
pub camera: CameraStats,
|
||||
/// Chunk streaming state, owned by [`crate::chunks`].
|
||||
pub chunks: Option<ChunkStats>,
|
||||
/// What the renderer submitted on its most recent frame.
|
||||
pub render: Option<RenderStats>,
|
||||
/// The physical device the renderer selected.
|
||||
pub gpu: Option<&'a GpuInfo>,
|
||||
/// Live device memory figures.
|
||||
pub memory: Option<MemoryUsage>,
|
||||
/// Transport counters and QUIC path statistics.
|
||||
pub net: Option<net::NetStats>,
|
||||
/// The server's own most recent report, delivered over the authority stream.
|
||||
pub server: Option<ServerStats>,
|
||||
/// Session identity, present once the handshake has completed.
|
||||
pub identity: Option<&'a ServerIdentity>,
|
||||
/// Immutable host facts.
|
||||
pub host: &'a HostInfo,
|
||||
/// Host and process figures for this window.
|
||||
pub usage: HostUsage,
|
||||
}
|
||||
|
||||
/// Renders a snapshot as a multi-line panel.
|
||||
///
|
||||
/// Emission goes through a single `tracing` event rather than many, so the panel arrives as one cohesive block rather than interleaved with concurrent output from other threads. Sections whose source is absent are omitted entirely rather than printed as placeholders.
|
||||
#[must_use]
|
||||
#[expect(
|
||||
clippy::too_many_lines,
|
||||
reason = "a formatter is one statement per reported field; splitting it would only scatter the layout"
|
||||
)]
|
||||
pub fn format_panel(snapshot: &Snapshot) -> String {
|
||||
let mut out = String::with_capacity(2048);
|
||||
let camera = &snapshot.camera;
|
||||
let frame = &snapshot.frame;
|
||||
|
||||
// `write!` into a String cannot fail, so the results are discarded rather than propagated.
|
||||
let _ = writeln!(out, "── debug statistics ──");
|
||||
let _ = writeln!(
|
||||
out,
|
||||
"frame {:.1} fps mean {:.2} ms min {:.2} ms max {:.2} ms ({} frames)",
|
||||
frame.average_fps,
|
||||
frame.mean_frame_ms,
|
||||
frame.min_frame_ms,
|
||||
frame.max_frame_ms,
|
||||
frame.frames
|
||||
);
|
||||
let _ = writeln!(
|
||||
out,
|
||||
"pos {:.2} {:.2} {:.2} block {} {} {} speed {:.2} b/s",
|
||||
camera.position.x,
|
||||
camera.position.y,
|
||||
camera.position.z,
|
||||
camera.block.x,
|
||||
camera.block.y,
|
||||
camera.block.z,
|
||||
camera.speed
|
||||
);
|
||||
let _ = writeln!(
|
||||
out,
|
||||
"chunk {} {} {} local {} {} {} facing {} ({}) yaw {:.1} pitch {:.1}",
|
||||
camera.chunk.x,
|
||||
camera.chunk.y,
|
||||
camera.chunk.z,
|
||||
camera.local.x,
|
||||
camera.local.y,
|
||||
camera.local.z,
|
||||
camera.facing,
|
||||
camera.facing_axis,
|
||||
camera.yaw_degrees,
|
||||
camera.pitch_degrees
|
||||
);
|
||||
|
||||
if let Some(chunks) = snapshot.chunks {
|
||||
let _ = writeln!(
|
||||
out,
|
||||
"chunks resident {} / desired {} radius {} remesh {} in-flight {} {:.1} MiB",
|
||||
chunks.resident,
|
||||
chunks.desired,
|
||||
chunks.load_radius,
|
||||
chunks.pending_remesh,
|
||||
chunks.in_flight,
|
||||
mib(chunks.resident_bytes)
|
||||
);
|
||||
let _ = writeln!(
|
||||
out,
|
||||
" loaded {} dropped {} evicted {} dispatched {} applied {} workers {}",
|
||||
chunks.loaded_total,
|
||||
chunks.dropped_total,
|
||||
chunks.evicted_total,
|
||||
chunks.dispatched_total,
|
||||
chunks.applied_total,
|
||||
chunks.mesh_workers
|
||||
);
|
||||
}
|
||||
|
||||
if let Some(render) = snapshot.render {
|
||||
let _ = writeln!(
|
||||
out,
|
||||
"render {:?} meshes {} uploaded / {} visible / {} culled ({:.1}%) draws {}",
|
||||
render.render_mode,
|
||||
render.uploaded_meshes,
|
||||
render.visible_meshes,
|
||||
render.culled_meshes,
|
||||
render.cull_ratio_percent(),
|
||||
render.draw_calls
|
||||
);
|
||||
let _ = writeln!(
|
||||
out,
|
||||
" tris {} verts {} buffers {:.1} MiB vtx / {:.1} MiB idx presented {} skipped {}",
|
||||
render.triangles,
|
||||
render.vertices,
|
||||
mib(render.vertex_bytes),
|
||||
mib(render.index_bytes),
|
||||
render.frames_presented,
|
||||
render.frames_skipped
|
||||
);
|
||||
let _ = writeln!(
|
||||
out,
|
||||
" swapchain {}x{} x{} {} fov {:.1} near {} far {} aspect {:.3}",
|
||||
render.swapchain.width,
|
||||
render.swapchain.height,
|
||||
render.swapchain.image_count,
|
||||
render.swapchain.present_mode,
|
||||
render.projection.fov_y_radians.to_degrees(),
|
||||
render.projection.near,
|
||||
render.projection.far,
|
||||
render.projection.aspect
|
||||
);
|
||||
}
|
||||
|
||||
if let Some(gpu) = snapshot.gpu {
|
||||
let _ = writeln!(
|
||||
out,
|
||||
"gpu {} ({}) vendor {:#06x} device {:#06x}",
|
||||
gpu.device_name, gpu.device_type, gpu.vendor_id, gpu.device_id
|
||||
);
|
||||
let _ = writeln!(
|
||||
out,
|
||||
" driver {} vulkan {} vram {:.0} MiB",
|
||||
gpu.driver_version,
|
||||
gpu.api_version,
|
||||
mib(gpu.vram_total_bytes)
|
||||
);
|
||||
}
|
||||
|
||||
if let Some(memory) = snapshot.memory {
|
||||
let heap = match (memory.heap_usage_bytes, memory.heap_budget_bytes) {
|
||||
(Some(used), Some(budget)) => {
|
||||
format!("heap {:.0} / {:.0} MiB", mib(used), mib(budget))
|
||||
}
|
||||
// The extension is absent, so the driver publishes no figure to report.
|
||||
_ => "heap unavailable".to_owned(),
|
||||
};
|
||||
let _ = writeln!(
|
||||
out,
|
||||
"vram {heap} allocator {:.1} / {:.1} MiB",
|
||||
mib(memory.allocator_allocated_bytes),
|
||||
mib(memory.allocator_capacity_bytes)
|
||||
);
|
||||
}
|
||||
|
||||
if let Some(identity) = snapshot.identity {
|
||||
let _ = writeln!(
|
||||
out,
|
||||
"server {} {} build {} protocol {} nominal {} Hz",
|
||||
identity.kind.label(),
|
||||
identity.address,
|
||||
identity.server_build,
|
||||
identity.protocol_version,
|
||||
identity.tick_rate_hint
|
||||
);
|
||||
}
|
||||
|
||||
if let Some(net) = snapshot.net {
|
||||
let _ = writeln!(
|
||||
out,
|
||||
"net {} rtt {:.1} ms cwnd {} lost {} mtu {}",
|
||||
if net.connected {
|
||||
"connected"
|
||||
} else {
|
||||
"disconnected"
|
||||
},
|
||||
net.rtt_ms,
|
||||
net.congestion_window,
|
||||
net.lost_packets,
|
||||
net.path_mtu
|
||||
);
|
||||
let _ = writeln!(
|
||||
out,
|
||||
" tx {:.2} MiB / {} dgram rx {:.2} MiB / {} dgram chunks {} drops {} subs {}",
|
||||
mib(net.bytes_sent),
|
||||
net.datagrams_sent,
|
||||
mib(net.bytes_received),
|
||||
net.datagrams_received,
|
||||
net.chunks_received,
|
||||
net.drops_received,
|
||||
net.subscribes_sent
|
||||
);
|
||||
}
|
||||
|
||||
if let Some(server) = snapshot.server {
|
||||
let _ = writeln!(
|
||||
out,
|
||||
"tick {:.1} tps mean {:.2} ms max {:.2} ms budget {:.0}% uptime {} s",
|
||||
server.measured_tps,
|
||||
server.mean_tick_ms,
|
||||
server.max_tick_ms,
|
||||
server.tick_budget_percent,
|
||||
server.uptime_secs
|
||||
);
|
||||
let _ = writeln!(
|
||||
out,
|
||||
"world chunks {} resident / {} in flight clients {} entities {} players {}",
|
||||
server.loaded_chunks,
|
||||
server.chunks_in_flight,
|
||||
server.connected_clients,
|
||||
server.entities,
|
||||
server.players
|
||||
);
|
||||
}
|
||||
|
||||
let host = snapshot.host;
|
||||
let usage = snapshot.usage;
|
||||
let _ = writeln!(
|
||||
out,
|
||||
"host {} x{} @ {} MHz {} kernel {}",
|
||||
host.cpu_brand, host.logical_cores, usage.cpu_frequency_mhz, host.os, host.kernel
|
||||
);
|
||||
let _ = write!(
|
||||
out,
|
||||
"proc build {} cpu {:.1}% rss {:.1} MiB virt {:.1} MiB system {:.0} / {:.0} MiB free",
|
||||
host.client_build,
|
||||
usage.process_cpu_percent,
|
||||
mib(usage.process_memory_bytes),
|
||||
mib(usage.process_virtual_bytes),
|
||||
mib(usage.system_available_bytes),
|
||||
mib(usage.system_total_bytes)
|
||||
);
|
||||
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tests/stats.rs"]
|
||||
mod tests;
|
||||
273
crates/client/src/tests/chunks.rs
Normal file
273
crates/client/src/tests/chunks.rs
Normal file
|
|
@ -0,0 +1,273 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
//! Unit tests for the chunk streaming logic in [`crate::chunks`].
|
||||
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use shared::world::BlockId;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn center_is_always_included() {
|
||||
let center = ChunkPos::new(0, 0, 0);
|
||||
assert!(desired_chunks(center, 4).contains(¢er));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn excludes_columns_beyond_the_disc() {
|
||||
let set = desired_chunks(ChunkPos::new(0, 0, 0), 4);
|
||||
// One chunk past the radius along an axis: squared distance 25 > 16.
|
||||
assert!(!set.contains(&ChunkPos::new(5, 0, 0)));
|
||||
// The far corner: squared distance 4*4 + 4*4 = 32 > 16.
|
||||
assert!(!set.contains(&ChunkPos::new(4, 0, 4)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vertical_extent_is_half_the_radius() {
|
||||
let set = desired_chunks(ChunkPos::new(0, 0, 0), 4);
|
||||
// radius / 2 == 2, so the column at the center spans y in [-2, 2].
|
||||
assert!(set.contains(&ChunkPos::new(0, 2, 0)));
|
||||
assert!(!set.contains(&ChunkPos::new(0, 3, 0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_is_translation_invariant() {
|
||||
// Shifting the center shifts every member by the same offset; this also exercises negative coordinates on the shifted side.
|
||||
let base = desired_chunks(ChunkPos::new(0, 0, 0), 3);
|
||||
let shifted: HashSet<ChunkPos> = base
|
||||
.iter()
|
||||
.map(|p| ChunkPos::new(p.x - 10, p.y - 10, p.z - 10))
|
||||
.collect();
|
||||
assert_eq!(shifted, desired_chunks(ChunkPos::new(-10, -10, -10), 3));
|
||||
}
|
||||
|
||||
/// Builds a residency predicate over a fixed set of positions.
|
||||
fn resident_in(set: &[ChunkPos]) -> impl Fn(ChunkPos) -> bool + '_ {
|
||||
move |pos| set.contains(&pos)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loaded_chunk_remeshes_self_and_resident_neighbors() {
|
||||
let p = ChunkPos::new(0, 0, 0);
|
||||
let east = ChunkPos::new(1, 0, 0);
|
||||
let down = ChunkPos::new(0, -1, 0);
|
||||
// p plus two of its six neighbours are resident; the other four are not.
|
||||
let resident = [p, east, down];
|
||||
let targets = remesh_targets(&[p], &[], resident_in(&resident));
|
||||
assert_eq!(targets, resident.into_iter().collect());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dropped_chunk_remeshes_neighbors_but_not_itself() {
|
||||
let p = ChunkPos::new(0, 0, 0);
|
||||
let neighbor = ChunkPos::new(1, 0, 0);
|
||||
let resident = [neighbor];
|
||||
let targets = remesh_targets(&[], &[p], resident_in(&resident));
|
||||
// The dropped chunk is never a target; its resident neighbour is.
|
||||
assert!(!targets.contains(&p));
|
||||
assert_eq!(targets, [neighbor].into_iter().collect());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remesh_targets_are_deduplicated() {
|
||||
// Two adjacent chunks loaded in one batch each name the other as a neighbour, but the set holds each once.
|
||||
let a = ChunkPos::new(0, 0, 0);
|
||||
let b = ChunkPos::new(1, 0, 0);
|
||||
let resident = [a, b];
|
||||
let targets = remesh_targets(&[a, b], &[], resident_in(&resident));
|
||||
assert_eq!(targets, resident.into_iter().collect());
|
||||
}
|
||||
|
||||
// --- Staleness decision (`should_apply`) ---------------------------------
|
||||
|
||||
#[test]
|
||||
fn should_apply_accepts_current_result() {
|
||||
let pos = ChunkPos::new(1, 2, 3);
|
||||
let generation = JobGen::FIRST;
|
||||
let mut in_flight = HashMap::new();
|
||||
in_flight.insert(pos, generation);
|
||||
// Resident and generation matches the outstanding job: apply.
|
||||
assert!(should_apply(pos, generation, |_| true, &in_flight));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_apply_rejects_stale_generation() {
|
||||
let pos = ChunkPos::new(0, 0, 0);
|
||||
let mut in_flight = HashMap::new();
|
||||
// A newer job (next generation) is outstanding for the position.
|
||||
in_flight.insert(pos, JobGen::FIRST.next());
|
||||
// The result carries the older generation and must be discarded.
|
||||
assert!(!should_apply(pos, JobGen::FIRST, |_| true, &in_flight));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_apply_rejects_unwanted_position() {
|
||||
let pos = ChunkPos::new(0, 0, 0);
|
||||
let generation = JobGen::FIRST;
|
||||
let mut in_flight = HashMap::new();
|
||||
in_flight.insert(pos, generation);
|
||||
// The position is no longer resident even though a job is tracked.
|
||||
assert!(!should_apply(pos, generation, |_| false, &in_flight));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_apply_rejects_missing_in_flight() {
|
||||
let pos = ChunkPos::new(0, 0, 0);
|
||||
// No job is tracked for the position (it was evicted after dispatch).
|
||||
let in_flight = HashMap::new();
|
||||
assert!(!should_apply(pos, JobGen::FIRST, |_| true, &in_flight));
|
||||
}
|
||||
|
||||
// --- Ingest pipeline plumbing --------------------------------------------
|
||||
|
||||
/// Recording [`MeshSink`] double capturing the keys passed to it, so ingest can be exercised without a GPU.
|
||||
#[derive(Default)]
|
||||
struct RecordingSink {
|
||||
/// Keys uploaded via [`MeshSink::insert_mesh`], in call order.
|
||||
inserted: Vec<MeshKey>,
|
||||
/// Keys cleared via [`MeshSink::remove_mesh`], in call order.
|
||||
removed: Vec<MeshKey>,
|
||||
}
|
||||
|
||||
impl MeshSink for RecordingSink {
|
||||
fn insert_mesh(
|
||||
&mut self,
|
||||
key: MeshKey,
|
||||
_vertices: &[Vertex],
|
||||
_indices: &[u32],
|
||||
_world_offset: [f32; 3],
|
||||
) -> Result<(), RendererError> {
|
||||
self.inserted.push(key);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn remove_mesh(&mut self, key: MeshKey) {
|
||||
self.removed.push(key);
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds a chunk with a single solid block so it meshes to non-empty geometry.
|
||||
fn solid_chunk() -> Chunk {
|
||||
let mut chunk = Chunk::default();
|
||||
chunk.set(0, 0, 0, BlockId(1));
|
||||
chunk
|
||||
}
|
||||
|
||||
/// Blocks until the pool yields a finished mesh, panicking if none arrives within a generous deadline.
|
||||
fn wait_for_result(pool: &MeshPool) -> MeshResult {
|
||||
let deadline = Instant::now() + Duration::from_secs(5);
|
||||
loop {
|
||||
if let Some(result) = pool.poll() {
|
||||
return result;
|
||||
}
|
||||
assert!(
|
||||
Instant::now() < deadline,
|
||||
"worker pool did not return a mesh within the deadline"
|
||||
);
|
||||
std::thread::sleep(Duration::from_millis(1));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finished_mesh_is_uploaded() {
|
||||
let mut manager = ChunkManager::new();
|
||||
let pos = ChunkPos::new(0, 0, 0);
|
||||
manager.resident.insert(pos, Arc::new(solid_chunk()));
|
||||
manager.pending_remesh.insert(pos);
|
||||
|
||||
assert_eq!(manager.dispatch_pending(), 1);
|
||||
let result = wait_for_result(&manager.pool);
|
||||
|
||||
let mut sink = RecordingSink::default();
|
||||
assert!(manager.apply_result(&result, &mut sink));
|
||||
// A non-empty mesh is uploaded once and the in-flight entry is cleared.
|
||||
assert_eq!(sink.inserted, vec![(0, 0, 0)]);
|
||||
assert!(sink.removed.is_empty());
|
||||
assert!(!manager.in_flight.contains_key(&pos));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn superseded_mesh_is_discarded() {
|
||||
let mut manager = ChunkManager::new();
|
||||
let pos = ChunkPos::new(0, 0, 0);
|
||||
manager.resident.insert(pos, Arc::new(solid_chunk()));
|
||||
manager.pending_remesh.insert(pos);
|
||||
|
||||
manager.dispatch_pending();
|
||||
let stale = wait_for_result(&manager.pool);
|
||||
|
||||
// A newer job supersedes the outstanding one before the first result is applied.
|
||||
manager.pending_remesh.insert(pos);
|
||||
manager.dispatch_pending();
|
||||
|
||||
let mut sink = RecordingSink::default();
|
||||
assert!(!manager.apply_result(&stale, &mut sink));
|
||||
assert!(sink.inserted.is_empty());
|
||||
assert!(sink.removed.is_empty());
|
||||
// The newer job remains tracked as outstanding.
|
||||
assert!(manager.in_flight.contains_key(&pos));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn evicted_mesh_is_discarded() {
|
||||
let mut manager = ChunkManager::new();
|
||||
let pos = ChunkPos::new(0, 0, 0);
|
||||
manager.resident.insert(pos, Arc::new(solid_chunk()));
|
||||
manager.pending_remesh.insert(pos);
|
||||
|
||||
manager.dispatch_pending();
|
||||
let result = wait_for_result(&manager.pool);
|
||||
|
||||
// The chunk leaves the load radius before its mesh arrives.
|
||||
manager.resident.remove(&pos);
|
||||
manager.in_flight.remove(&pos);
|
||||
|
||||
let mut sink = RecordingSink::default();
|
||||
assert!(!manager.apply_result(&result, &mut sink));
|
||||
assert!(sink.inserted.is_empty());
|
||||
assert!(sink.removed.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stats_report_live_pipeline_state() {
|
||||
let mut manager = ChunkManager::new();
|
||||
let center = ChunkPos::new(0, 0, 0);
|
||||
manager
|
||||
.resident
|
||||
.insert(center, Arc::new(ChunkManager::new().baseline.clone()));
|
||||
manager.pending_remesh.insert(ChunkPos::new(1, 0, 0));
|
||||
|
||||
let stats = manager.stats(center);
|
||||
assert_eq!(stats.resident, 1);
|
||||
assert_eq!(stats.pending_remesh, 1);
|
||||
assert_eq!(stats.in_flight, 0);
|
||||
assert_eq!(stats.load_radius, LOAD_RADIUS);
|
||||
assert_eq!(stats.desired, desired_chunks(center, LOAD_RADIUS).len());
|
||||
// One resident chunk accounts for exactly one chunk's worth of voxel storage.
|
||||
assert_eq!(stats.resident_bytes, CHUNK_RESIDENT_BYTES);
|
||||
assert!(stats.mesh_workers >= 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn totals_accumulate_across_frames() {
|
||||
let mut totals = ChunkTotals::default();
|
||||
totals.accumulate(1, 2, 3, 4, 5);
|
||||
totals.accumulate(10, 20, 30, 40, 50);
|
||||
|
||||
assert_eq!(totals.loaded, 11);
|
||||
assert_eq!(totals.dropped, 22);
|
||||
assert_eq!(totals.evicted, 33);
|
||||
assert_eq!(totals.dispatched, 44);
|
||||
assert_eq!(totals.applied, 55);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn totals_saturate_rather_than_overflow() {
|
||||
let mut totals = ChunkTotals {
|
||||
loaded: u64::MAX,
|
||||
..ChunkTotals::default()
|
||||
};
|
||||
totals.accumulate(1, 0, 0, 0, 0);
|
||||
assert_eq!(totals.loaded, u64::MAX);
|
||||
}
|
||||
202
crates/client/src/tests/debug.rs
Normal file
202
crates/client/src/tests/debug.rs
Normal file
|
|
@ -0,0 +1,202 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
//! Unit tests for the debug chord handling in [`crate::debug`].
|
||||
|
||||
use super::*;
|
||||
|
||||
/// Presses and releases a key, returning the action produced on the press edge.
|
||||
fn tap(controls: &mut DebugControls, code: KeyCode) -> Option<DebugAction> {
|
||||
let action = controls.handle_key(code, true);
|
||||
controls.handle_key(code, false);
|
||||
action
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chord_key_alone_does_nothing() {
|
||||
let mut controls = DebugControls::default();
|
||||
assert_eq!(tap(&mut controls, KeyCode::KeyV), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn modifier_alone_produces_no_action() {
|
||||
let mut controls = DebugControls::default();
|
||||
assert_eq!(controls.handle_key(DEBUG_MODIFIER, true), None);
|
||||
assert_eq!(controls.handle_key(DEBUG_MODIFIER, false), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn held_modifier_plus_bound_key_selects_the_mode() {
|
||||
let mut controls = DebugControls::default();
|
||||
controls.handle_key(DEBUG_MODIFIER, true);
|
||||
assert_eq!(
|
||||
tap(&mut controls, KeyCode::KeyV),
|
||||
Some(DebugAction::SetRenderMode(RenderMode::FilledPoints))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_bound_key_selects_a_distinct_overlay_mode() {
|
||||
let mut controls = DebugControls::default();
|
||||
controls.handle_key(DEBUG_MODIFIER, true);
|
||||
for (code, expected) in [
|
||||
(KeyCode::KeyV, RenderMode::FilledPoints),
|
||||
(KeyCode::KeyB, RenderMode::FilledWireframe),
|
||||
] {
|
||||
assert_eq!(
|
||||
tap(&mut controls, code),
|
||||
Some(DebugAction::SetRenderMode(expected))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn solo_modifier_drops_the_filled_pass() {
|
||||
for solo in SOLO_MODIFIER {
|
||||
let mut controls = DebugControls::default();
|
||||
controls.handle_key(DEBUG_MODIFIER, true);
|
||||
controls.handle_key(solo, true);
|
||||
assert_eq!(
|
||||
tap(&mut controls, KeyCode::KeyV),
|
||||
Some(DebugAction::SetRenderMode(RenderMode::Points))
|
||||
);
|
||||
assert_eq!(
|
||||
tap(&mut controls, KeyCode::KeyB),
|
||||
Some(DebugAction::SetRenderMode(RenderMode::Wireframe))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn solo_modifier_is_tracked_before_the_debug_modifier() {
|
||||
let mut controls = DebugControls::default();
|
||||
controls.handle_key(KeyCode::ShiftLeft, true);
|
||||
controls.handle_key(DEBUG_MODIFIER, true);
|
||||
assert_eq!(
|
||||
tap(&mut controls, KeyCode::KeyV),
|
||||
Some(DebugAction::SetRenderMode(RenderMode::Points))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn releasing_the_solo_modifier_restores_the_overlay_form() {
|
||||
let mut controls = DebugControls::default();
|
||||
controls.handle_key(DEBUG_MODIFIER, true);
|
||||
controls.handle_key(KeyCode::ShiftLeft, true);
|
||||
tap(&mut controls, KeyCode::KeyV);
|
||||
controls.handle_key(KeyCode::ShiftLeft, false);
|
||||
assert_eq!(
|
||||
tap(&mut controls, KeyCode::KeyV),
|
||||
Some(DebugAction::SetRenderMode(RenderMode::FilledPoints))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn switching_between_modes_does_not_pass_through_filled() {
|
||||
let mut controls = DebugControls::default();
|
||||
controls.handle_key(DEBUG_MODIFIER, true);
|
||||
tap(&mut controls, KeyCode::KeyV);
|
||||
assert_eq!(
|
||||
tap(&mut controls, KeyCode::KeyB),
|
||||
Some(DebugAction::SetRenderMode(RenderMode::FilledWireframe))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeating_the_chord_toggles_back_to_filled() {
|
||||
let mut controls = DebugControls::default();
|
||||
controls.handle_key(DEBUG_MODIFIER, true);
|
||||
tap(&mut controls, KeyCode::KeyV);
|
||||
assert_eq!(
|
||||
tap(&mut controls, KeyCode::KeyV),
|
||||
Some(DebugAction::SetRenderMode(RenderMode::Filled))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn action_fires_on_the_press_edge_only() {
|
||||
let mut controls = DebugControls::default();
|
||||
controls.handle_key(DEBUG_MODIFIER, true);
|
||||
assert!(controls.handle_key(KeyCode::KeyV, true).is_some());
|
||||
assert_eq!(controls.handle_key(KeyCode::KeyV, false), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn releasing_the_modifier_disarms_the_chord() {
|
||||
let mut controls = DebugControls::default();
|
||||
controls.handle_key(DEBUG_MODIFIER, true);
|
||||
controls.handle_key(DEBUG_MODIFIER, false);
|
||||
assert_eq!(tap(&mut controls, KeyCode::KeyV), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unbound_key_under_the_modifier_is_ignored() {
|
||||
let mut controls = DebugControls::default();
|
||||
controls.handle_key(DEBUG_MODIFIER, true);
|
||||
assert_eq!(tap(&mut controls, KeyCode::KeyW), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn solo_modifier_alone_produces_no_action() {
|
||||
let mut controls = DebugControls::default();
|
||||
assert_eq!(controls.handle_key(KeyCode::ShiftLeft, true), None);
|
||||
assert_eq!(tap(&mut controls, KeyCode::KeyV), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_stats_chord_toggles_the_overlay_on_and_off() {
|
||||
let mut controls = DebugControls::default();
|
||||
controls.handle_key(DEBUG_MODIFIER, true);
|
||||
|
||||
assert_eq!(
|
||||
tap(&mut controls, STATS_KEY),
|
||||
Some(DebugAction::SetStatsOverlay(true))
|
||||
);
|
||||
assert_eq!(
|
||||
tap(&mut controls, STATS_KEY),
|
||||
Some(DebugAction::SetStatsOverlay(false))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_stats_chord_leaves_the_render_mode_untouched() {
|
||||
let mut controls = DebugControls::default();
|
||||
controls.handle_key(DEBUG_MODIFIER, true);
|
||||
tap(&mut controls, KeyCode::KeyV);
|
||||
|
||||
tap(&mut controls, STATS_KEY);
|
||||
|
||||
// The raster axis must survive a toggle on the statistics axis; a single-enum design would have reset it.
|
||||
assert_eq!(controls.render_mode, RenderMode::FilledPoints);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_raster_chord_leaves_the_stats_overlay_untouched() {
|
||||
let mut controls = DebugControls::default();
|
||||
controls.handle_key(DEBUG_MODIFIER, true);
|
||||
tap(&mut controls, STATS_KEY);
|
||||
|
||||
assert_eq!(
|
||||
tap(&mut controls, KeyCode::KeyB),
|
||||
Some(DebugAction::SetRenderMode(RenderMode::FilledWireframe))
|
||||
);
|
||||
assert!(controls.stats_enabled);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_solo_modifier_does_not_change_the_stats_chord() {
|
||||
let mut controls = DebugControls::default();
|
||||
controls.handle_key(DEBUG_MODIFIER, true);
|
||||
controls.handle_key(KeyCode::ShiftLeft, true);
|
||||
|
||||
assert_eq!(
|
||||
tap(&mut controls, STATS_KEY),
|
||||
Some(DebugAction::SetStatsOverlay(true))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_stats_chord_requires_the_debug_modifier() {
|
||||
let mut controls = DebugControls::default();
|
||||
assert_eq!(tap(&mut controls, STATS_KEY), None);
|
||||
assert!(!controls.stats_enabled);
|
||||
}
|
||||
285
crates/client/src/tests/stats.rs
Normal file
285
crates/client/src/tests/stats.rs
Normal file
|
|
@ -0,0 +1,285 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
//! Unit tests for the statistics accumulator, derived arithmetic, and formatter in [`crate::stats`].
|
||||
|
||||
use super::*;
|
||||
|
||||
/// Asserts two f32 values agree to within a tolerance that survives the accumulated division and multiplication.
|
||||
fn close(actual: f32, expected: f32) {
|
||||
assert!(
|
||||
(actual - expected).abs() < 0.01,
|
||||
"expected {expected}, got {actual}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_window_reports_zeroes_rather_than_dividing_by_zero() {
|
||||
let stats = summarise_frames(0, 0.0, f32::INFINITY, 0.0, STATS_INTERVAL);
|
||||
|
||||
close(stats.average_fps, 0.0);
|
||||
close(stats.mean_frame_ms, 0.0);
|
||||
// The sentinel the accumulator starts from must not leak into the reported minimum.
|
||||
close(stats.min_frame_ms, 0.0);
|
||||
assert_eq!(stats.frames, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_steady_window_reports_the_matching_rate_and_frametime() {
|
||||
// Sixty frames of 16.667 ms each, filling one second of wall clock.
|
||||
let stats = summarise_frames(60, 1.0, 1.0 / 60.0, 1.0 / 60.0, Duration::from_secs(1));
|
||||
|
||||
close(stats.average_fps, 60.0);
|
||||
close(stats.mean_frame_ms, 16.67);
|
||||
close(stats.min_frame_ms, 16.67);
|
||||
close(stats.max_frame_ms, 16.67);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_spike_is_visible_in_the_maximum_while_the_mean_stays_flat() {
|
||||
// Fifty-nine cheap frames plus one 40 ms stall: an average of sixty frames per second conceals what the maximum exposes.
|
||||
let stats = summarise_frames(60, 1.0, 0.010, 0.040, Duration::from_secs(1));
|
||||
|
||||
close(stats.average_fps, 60.0);
|
||||
close(stats.mean_frame_ms, 16.67);
|
||||
close(stats.max_frame_ms, 40.0);
|
||||
close(stats.min_frame_ms, 10.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_single_frame_window_is_summarised_without_special_casing() {
|
||||
let stats = summarise_frames(1, 0.5, 0.5, 0.5, Duration::from_secs(1));
|
||||
|
||||
close(stats.average_fps, 1.0);
|
||||
close(stats.mean_frame_ms, 500.0);
|
||||
close(stats.max_frame_ms, 500.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_delta_frames_do_not_produce_a_non_finite_frametime() {
|
||||
let stats = summarise_frames(4, 0.0, 0.0, 0.0, Duration::from_secs(1));
|
||||
|
||||
assert!(stats.mean_frame_ms.is_finite());
|
||||
close(stats.mean_frame_ms, 0.0);
|
||||
close(stats.average_fps, 4.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_window_closes_only_once_the_interval_has_elapsed() {
|
||||
let start = Instant::now();
|
||||
let mut accumulator = FrameAccumulator::new(start);
|
||||
accumulator.record(0.016);
|
||||
|
||||
assert!(
|
||||
accumulator
|
||||
.take_window(start + Duration::from_millis(999))
|
||||
.is_none()
|
||||
);
|
||||
assert!(accumulator.take_window(start + STATS_INTERVAL).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closing_a_window_resets_the_accumulators() {
|
||||
let start = Instant::now();
|
||||
let mut accumulator = FrameAccumulator::new(start);
|
||||
accumulator.record(0.100);
|
||||
let _ = accumulator.take_window(start + STATS_INTERVAL);
|
||||
|
||||
accumulator.record(0.010);
|
||||
let second = accumulator
|
||||
.take_window(start + STATS_INTERVAL + STATS_INTERVAL)
|
||||
.unwrap_or(FrameStats {
|
||||
average_fps: 0.0,
|
||||
mean_frame_ms: 0.0,
|
||||
min_frame_ms: 0.0,
|
||||
max_frame_ms: 0.0,
|
||||
frames: 0,
|
||||
});
|
||||
|
||||
// The 100 ms frame belonged to the first window and must not leak into the second's extremes.
|
||||
assert_eq!(second.frames, 1);
|
||||
close(second.max_frame_ms, 10.0);
|
||||
close(second.min_frame_ms, 10.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_negative_position_floors_into_the_block_below_rather_than_truncating_toward_zero() {
|
||||
let stats = camera_stats(
|
||||
Vec3::new(-0.5, 1.5, -33.0),
|
||||
Vec3::NEG_Z,
|
||||
0.0,
|
||||
0.0,
|
||||
Vec3::ZERO,
|
||||
0.0,
|
||||
);
|
||||
|
||||
assert_eq!(stats.block, IVec3::new(-1, 1, -33));
|
||||
// Chunk-local coordinates stay non-negative on the negative side of the origin.
|
||||
assert!(stats.local.cmpge(IVec3::ZERO).all());
|
||||
assert_eq!(usize::try_from(stats.local.x), Ok(CHUNK_SIZE - 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_horizontal_direction_maps_to_its_cardinal_and_axis() {
|
||||
for (forward, expected) in [
|
||||
(Vec3::X, ("east", "+X")),
|
||||
(Vec3::NEG_X, ("west", "-X")),
|
||||
(Vec3::Z, ("south", "+Z")),
|
||||
(Vec3::NEG_Z, ("north", "-Z")),
|
||||
] {
|
||||
assert_eq!(facing_for(forward), expected);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pitch_does_not_change_the_reported_cardinal_direction() {
|
||||
// A steeply downward vector still faces north, since only the horizontal components decide.
|
||||
assert_eq!(facing_for(Vec3::new(0.0, -0.99, -0.1)), ("north", "-Z"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn speed_is_the_distance_covered_over_the_frame_delta() {
|
||||
let stats = camera_stats(
|
||||
Vec3::ZERO,
|
||||
Vec3::NEG_Z,
|
||||
0.0,
|
||||
0.0,
|
||||
Vec3::new(3.0, 4.0, 0.0),
|
||||
0.5,
|
||||
);
|
||||
|
||||
// A 5-block displacement over half a second is ten blocks per second.
|
||||
close(stats.speed, 10.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_zero_frame_delta_reports_no_speed_rather_than_infinity() {
|
||||
let stats = camera_stats(Vec3::ZERO, Vec3::NEG_Z, 0.0, 0.0, Vec3::X, 0.0);
|
||||
|
||||
assert!(stats.speed.is_finite());
|
||||
close(stats.speed, 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn yaw_and_pitch_are_reported_in_degrees() {
|
||||
let stats = camera_stats(
|
||||
Vec3::ZERO,
|
||||
Vec3::NEG_Z,
|
||||
std::f32::consts::PI,
|
||||
std::f32::consts::FRAC_PI_2,
|
||||
Vec3::ZERO,
|
||||
0.0,
|
||||
);
|
||||
|
||||
close(stats.yaw_degrees, 180.0);
|
||||
close(stats.pitch_degrees, 90.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn byte_counts_convert_to_mebibytes_on_the_binary_scale() {
|
||||
close(mib(1024 * 1024), 1.0);
|
||||
close(mib(0), 0.0);
|
||||
close(mib(1024 * 1024 * 3 / 2), 1.5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_formatter_emits_every_always_present_section() {
|
||||
let host = HostInfo {
|
||||
cpu_brand: "Test CPU".to_owned(),
|
||||
logical_cores: 8,
|
||||
os: "Test OS".to_owned(),
|
||||
kernel: "1.2.3".to_owned(),
|
||||
client_build: "0.0.0",
|
||||
};
|
||||
let snapshot = Snapshot {
|
||||
frame: FrameStats {
|
||||
average_fps: 60.0,
|
||||
mean_frame_ms: 16.67,
|
||||
min_frame_ms: 15.0,
|
||||
max_frame_ms: 40.0,
|
||||
frames: 60,
|
||||
},
|
||||
camera: camera_stats(
|
||||
Vec3::new(1.0, 2.0, 3.0),
|
||||
Vec3::NEG_Z,
|
||||
0.0,
|
||||
0.0,
|
||||
Vec3::ZERO,
|
||||
0.0,
|
||||
),
|
||||
chunks: None,
|
||||
render: None,
|
||||
gpu: None,
|
||||
memory: None,
|
||||
net: None,
|
||||
server: None,
|
||||
identity: None,
|
||||
host: &host,
|
||||
usage: HostUsage {
|
||||
process_cpu_percent: 12.5,
|
||||
process_memory_bytes: 1024 * 1024,
|
||||
process_virtual_bytes: 2 * 1024 * 1024,
|
||||
system_total_bytes: 16 * 1024 * 1024,
|
||||
system_available_bytes: 8 * 1024 * 1024,
|
||||
cpu_frequency_mhz: 4200,
|
||||
},
|
||||
};
|
||||
|
||||
let panel = format_panel(&snapshot);
|
||||
|
||||
assert!(panel.contains("60.0 fps"), "{panel}");
|
||||
assert!(panel.contains("max 40.00 ms"), "{panel}");
|
||||
assert!(panel.contains("facing north (-Z)"), "{panel}");
|
||||
assert!(panel.contains("Test CPU x8 @ 4200 MHz"), "{panel}");
|
||||
assert!(panel.contains("cpu 12.5%"), "{panel}");
|
||||
assert!(panel.contains("rss 1.0 MiB"), "{panel}");
|
||||
// Sections whose source is absent are omitted rather than printed as placeholders.
|
||||
assert!(!panel.contains("gpu "), "{panel}");
|
||||
assert!(!panel.contains("net "), "{panel}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn absent_device_memory_figures_are_named_rather_than_reported_as_zero() {
|
||||
let host = HostInfo {
|
||||
cpu_brand: "Test CPU".to_owned(),
|
||||
logical_cores: 1,
|
||||
os: "Test OS".to_owned(),
|
||||
kernel: "1.2.3".to_owned(),
|
||||
client_build: "0.0.0",
|
||||
};
|
||||
let snapshot = Snapshot {
|
||||
frame: FrameStats {
|
||||
average_fps: 0.0,
|
||||
mean_frame_ms: 0.0,
|
||||
min_frame_ms: 0.0,
|
||||
max_frame_ms: 0.0,
|
||||
frames: 0,
|
||||
},
|
||||
camera: camera_stats(Vec3::ZERO, Vec3::NEG_Z, 0.0, 0.0, Vec3::ZERO, 0.0),
|
||||
chunks: None,
|
||||
render: None,
|
||||
gpu: None,
|
||||
memory: Some(MemoryUsage {
|
||||
heap_usage_bytes: None,
|
||||
heap_budget_bytes: None,
|
||||
allocator_allocated_bytes: 1024 * 1024,
|
||||
allocator_capacity_bytes: 2 * 1024 * 1024,
|
||||
}),
|
||||
net: None,
|
||||
server: None,
|
||||
identity: None,
|
||||
host: &host,
|
||||
usage: HostUsage {
|
||||
process_cpu_percent: 0.0,
|
||||
process_memory_bytes: 0,
|
||||
process_virtual_bytes: 0,
|
||||
system_total_bytes: 0,
|
||||
system_available_bytes: 0,
|
||||
cpu_frequency_mhz: 0,
|
||||
},
|
||||
};
|
||||
|
||||
let panel = format_panel(&snapshot);
|
||||
|
||||
assert!(panel.contains("heap unavailable"), "{panel}");
|
||||
assert!(panel.contains("allocator 1.0 / 2.0 MiB"), "{panel}");
|
||||
}
|
||||
21
crates/net/Cargo.toml
Normal file
21
crates/net/Cargo.toml
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
[package]
|
||||
name = "net"
|
||||
license.workspace = true
|
||||
authors.workspace = true
|
||||
edition.workspace = true
|
||||
version.workspace = true
|
||||
|
||||
[dependencies]
|
||||
crossbeam-channel = "0.5.16"
|
||||
postcard.workspace = true
|
||||
quinn = "0.11.11"
|
||||
rcgen = "0.14.8"
|
||||
rustls = "0.23.41"
|
||||
serde.workspace = true
|
||||
thiserror.workspace = true
|
||||
tokio = { version = "1.52.3", features = ["rt-multi-thread", "macros", "net", "sync", "io-util", "time"] }
|
||||
tracing.workspace = true
|
||||
shared = { path = "../shared" }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
97
crates/net/src/authority.rs
Normal file
97
crates/net/src/authority.rs
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
//! Authority-stream transport: the per-connection task that pushes server-authoritative state to a client.
|
||||
|
||||
use shared::protocol::authority::AuthorityMessage;
|
||||
use tokio::sync::mpsc::error::TrySendError;
|
||||
use tokio::sync::mpsc::{Sender, UnboundedReceiver, UnboundedSender};
|
||||
use tracing::{debug, warn};
|
||||
|
||||
use crate::codec::{read_frame, write_frame};
|
||||
|
||||
/// Maximum accepted authority frame length, in bytes.
|
||||
///
|
||||
/// Authority payloads are small fixed-shape records; the bound is generous relative to a [`ServerStats`](shared::protocol::authority::ServerStats) and exists to cap what a malformed or hostile length prefix can make the peer allocate.
|
||||
pub const MAX_AUTHORITY_FRAME_LEN: usize = 64 * 1024;
|
||||
|
||||
/// A synchronous handle the simulation loop uses to push [`AuthorityMessage`]s to one connection.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AuthoritySink {
|
||||
/// Outbound queue drained by the connection's authority task.
|
||||
tx: UnboundedSender<AuthorityMessage>,
|
||||
}
|
||||
|
||||
impl AuthoritySink {
|
||||
/// Wraps `tx` as an authority sink.
|
||||
pub(crate) fn new(tx: UnboundedSender<AuthorityMessage>) -> Self {
|
||||
Self { tx }
|
||||
}
|
||||
|
||||
/// Queues `msg` for delivery on the connection's authority stream.
|
||||
///
|
||||
/// Non-blocking. A send failure means the receiving task has ended (the connection dropped); it is logged at debug and swallowed, since the simulation loop cannot act on a departed connection.
|
||||
pub fn send(&self, msg: AuthorityMessage) {
|
||||
if self.tx.send(msg).is_err() {
|
||||
debug!("authority sink send failed; connection task has ended");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs the server-side authority pump for one connection until the stream or connection closes.
|
||||
///
|
||||
/// Opens the unidirectional stream, then writes every [`AuthorityMessage`] handed over by the simulation loop. The loop ends when the sink is dropped (the connection is being torn down) or a write fails.
|
||||
pub(crate) async fn server_authority_task(
|
||||
connection: quinn::Connection,
|
||||
id: u64,
|
||||
mut outbound: UnboundedReceiver<AuthorityMessage>,
|
||||
) {
|
||||
let mut send = match connection.open_uni().await {
|
||||
Ok(stream) => stream,
|
||||
Err(error) => {
|
||||
warn!(%error, id, "failed to open authority stream");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
while let Some(message) = outbound.recv().await {
|
||||
if let Err(error) = write_frame(&mut send, &message).await {
|
||||
warn!(%error, id, "failed to write authority frame; ending authority stream");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs the client-side authority pump for one connection until the stream or connection closes.
|
||||
///
|
||||
/// Accepts the unidirectional stream the server opens, then forwards every decoded [`AuthorityMessage`] to the UI thread. The loop ends when the stream closes or the UI drops its receiver.
|
||||
pub(crate) async fn client_authority_task(
|
||||
connection: quinn::Connection,
|
||||
inbound: Sender<AuthorityMessage>,
|
||||
) {
|
||||
let mut recv = match connection.accept_uni().await {
|
||||
Ok(stream) => stream,
|
||||
Err(error) => {
|
||||
debug!(%error, "authority stream never opened");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
loop {
|
||||
match read_frame::<AuthorityMessage>(&mut recv, MAX_AUTHORITY_FRAME_LEN).await {
|
||||
Ok(message) => match inbound.try_send(message) {
|
||||
Ok(()) => {}
|
||||
// A full channel means the UI is behind on a purely diagnostic stream; dropping the newest message is preferable to blocking the read loop.
|
||||
Err(TrySendError::Full(_)) => {
|
||||
debug!("authority delivery dropped; UI queue is full");
|
||||
}
|
||||
// A closed channel means the UI has gone away, so there is nothing left to deliver to.
|
||||
Err(TrySendError::Closed(_)) => break,
|
||||
},
|
||||
Err(error) => {
|
||||
// A read error is the normal end of the session (stream finished or reset).
|
||||
debug!(%error, "authority stream read ended");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
184
crates/net/src/chunk.rs
Normal file
184
crates/net/src/chunk.rs
Normal file
|
|
@ -0,0 +1,184 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
//! Chunk-stream transport: the per-connection task that pumps chunk subscriptions and deliveries.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use shared::protocol::chunk::{ChunkMessage, ChunkSubscribe};
|
||||
use tokio::sync::mpsc::{Sender, UnboundedReceiver, UnboundedSender};
|
||||
use tracing::{debug, warn};
|
||||
|
||||
use crate::codec::{MAX_CHUNK_FRAME_LEN, read_frame, write_frame};
|
||||
use crate::runtime::ServerEvent;
|
||||
use crate::stats::NetCounters;
|
||||
|
||||
/// A synchronous handle the simulation loop uses to hand [`ChunkMessage`]s to a connection's chunk-stream task.
|
||||
///
|
||||
/// The wrapped channel is a `tokio` unbounded MPSC. Its `send` is synchronous and callable from the non-async simulation thread with no runtime in scope, while the connection's task drains the receiver with `recv().await` so it composes into the task's `select!`. The `tokio` sender type is kept private so the `server` crate never names it.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ChunkSink {
|
||||
/// Outbound queue drained by the connection's chunk-stream task.
|
||||
tx: UnboundedSender<ChunkMessage>,
|
||||
}
|
||||
|
||||
impl ChunkSink {
|
||||
/// Wraps `tx` as a chunk sink.
|
||||
pub(crate) fn new(tx: UnboundedSender<ChunkMessage>) -> Self {
|
||||
Self { tx }
|
||||
}
|
||||
|
||||
/// Queues `msg` for delivery on the connection's chunk stream.
|
||||
///
|
||||
/// Non-blocking. A send failure means the receiving task has ended (the connection dropped); it is logged at debug and swallowed, since the simulation loop cannot act on a departed connection.
|
||||
pub fn send(&self, msg: ChunkMessage) {
|
||||
if self.tx.send(msg).is_err() {
|
||||
debug!("chunk sink send failed; connection task has ended");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs the server-side chunk-stream pump for one connection until the stream or connection closes.
|
||||
///
|
||||
/// Accepts the connection's chunk stream, then loops: inbound [`ChunkSubscribe`] frames are forwarded to the simulation loop as [`ServerEvent::ChunkSubscribe`], and outbound [`ChunkMessage`]s taken from `outbound` are written onto the stream. The loop ends when the peer closes the stream, when the events receiver is gone (the server is shutting down), or when the outbound sink is dropped.
|
||||
pub(crate) async fn chunk_stream_task(
|
||||
connection: quinn::Connection,
|
||||
id: u64,
|
||||
events: crossbeam_channel::Sender<ServerEvent>,
|
||||
mut outbound: UnboundedReceiver<ChunkMessage>,
|
||||
) {
|
||||
// The client opens the chunk stream after the handshake; the server accepts it here, mirroring the control-stream convention.
|
||||
let (mut send, mut recv) = match connection.accept_bi().await {
|
||||
Ok(stream) => stream,
|
||||
Err(error) => {
|
||||
warn!(%error, id, "failed to accept chunk stream");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// Inbound subscriptions are read on their own future so `read_frame` is never cancelled mid-frame by an outbound write becoming ready.
|
||||
let reader = async {
|
||||
loop {
|
||||
match read_frame::<ChunkSubscribe>(&mut recv, MAX_CHUNK_FRAME_LEN).await {
|
||||
Ok(request) => {
|
||||
// A closed events receiver means the simulation loop is gone; nothing more to do.
|
||||
if events
|
||||
.send(ServerEvent::ChunkSubscribe { id, request })
|
||||
.is_err()
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
// A read error is the normal end of a client session (stream finished or reset).
|
||||
debug!(%error, id, "chunk stream read ended");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Outbound chunks handed back by the simulation loop are written on their own future. The loop ends when the sink is dropped (the connection is being torn down).
|
||||
let writer = async {
|
||||
while let Some(message) = outbound.recv().await {
|
||||
if let Err(error) = write_frame(&mut send, &message).await {
|
||||
warn!(%error, id, "failed to write chunk frame; ending chunk stream");
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// The task ends as soon as either direction closes; the other future is then dropped, abandoning the stream that is already being torn down.
|
||||
tokio::select! {
|
||||
() = reader => {}
|
||||
() = writer => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// A synchronous handle the client's UI thread uses to push [`ChunkSubscribe`] requests to its network task.
|
||||
///
|
||||
/// The client-side mirror of [`ChunkSink`]: `send` is synchronous and callable from the winit loop with no runtime in scope, while the client's chunk task drains the receiver with `recv().await` so it composes into that task's `select!`.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ChunkSubscriber {
|
||||
/// Outbound queue of subscription updates drained by the client's chunk task.
|
||||
tx: UnboundedSender<ChunkSubscribe>,
|
||||
}
|
||||
|
||||
impl ChunkSubscriber {
|
||||
/// Wraps `tx` as a chunk subscriber.
|
||||
pub(crate) fn new(tx: UnboundedSender<ChunkSubscribe>) -> Self {
|
||||
Self { tx }
|
||||
}
|
||||
|
||||
/// Queues a subscription update for the server.
|
||||
///
|
||||
/// Non-blocking. A send failure means the network task has ended (the connection dropped); it is logged at debug and swallowed, since the UI thread cannot act on a departed connection.
|
||||
pub fn send(&self, request: ChunkSubscribe) {
|
||||
if self.tx.send(request).is_err() {
|
||||
debug!("chunk subscriber send failed; network task has ended");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs the client-side chunk-stream pump for one connection until the stream or connection closes.
|
||||
///
|
||||
/// Opens the chunk stream, then loops: [`ChunkSubscribe`] requests taken from `subscribe` are written to the server, and inbound [`ChunkMessage`] frames are forwarded to the UI thread over `deliveries`. The loop ends when the UI drops its subscriber, when the delivery receiver is gone, or when the stream closes.
|
||||
pub(crate) async fn client_chunk_task(
|
||||
connection: quinn::Connection,
|
||||
mut subscribe: UnboundedReceiver<ChunkSubscribe>,
|
||||
deliveries: Sender<ChunkMessage>,
|
||||
counters: &Arc<NetCounters>,
|
||||
) {
|
||||
// The client opens the chunk stream after the handshake; the server accepts it, mirroring the control-stream convention.
|
||||
let (mut send, mut recv) = match connection.open_bi().await {
|
||||
Ok(stream) => stream,
|
||||
Err(error) => {
|
||||
warn!(%error, "failed to open chunk stream");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// Inbound chunks are read on their own future so `read_frame` is never cancelled mid-frame by an outbound subscribe becoming ready.
|
||||
let reader = async {
|
||||
loop {
|
||||
match read_frame::<ChunkMessage>(&mut recv, MAX_CHUNK_FRAME_LEN).await {
|
||||
Ok(message) => {
|
||||
// Counted on arrival rather than on delivery to the UI, so the figure reflects what the transport received even while the UI thread is backpressuring below.
|
||||
match message {
|
||||
ChunkMessage::Chunk { .. } => counters.record_chunk(),
|
||||
ChunkMessage::Drop { .. } => counters.record_drop(),
|
||||
}
|
||||
// `send` awaits when the delivery channel is full: the task suspends (yielding the runtime thread so the connection keeps ACKing) until the UI drains a slot, and until then reads no further frames, which backpressures the server via QUIC stream flow control. An error means the UI dropped its receiver, so the session ends.
|
||||
if deliveries.send(message).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
// A read error is the normal end of the session (stream finished or reset).
|
||||
debug!(%error, "chunk stream read ended");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Outbound subscription updates from the UI thread are written on their own future. The loop ends when the subscriber is dropped (the UI is shutting down).
|
||||
let writer = async {
|
||||
while let Some(request) = subscribe.recv().await {
|
||||
if let Err(error) = write_frame(&mut send, &request).await {
|
||||
warn!(%error, "failed to write chunk subscribe; ending chunk stream");
|
||||
break;
|
||||
}
|
||||
counters.record_subscribe();
|
||||
}
|
||||
};
|
||||
|
||||
// The task ends as soon as either direction closes; the other future is then dropped, abandoning the stream that is already being torn down.
|
||||
tokio::select! {
|
||||
() = reader => {}
|
||||
() = writer => {}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tests/chunk.rs"]
|
||||
mod tests;
|
||||
137
crates/net/src/codec.rs
Normal file
137
crates/net/src/codec.rs
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
//! Length-prefixed `postcard` frame codec.
|
||||
//!
|
||||
//! Encodes and decodes one logical protocol message per record on a QUIC stream, using a length prefix so a reader can recover record boundaries from a byte stream.
|
||||
|
||||
use crate::error::NetError;
|
||||
|
||||
/// The maximum payload length, in bytes, accepted on the control stream (64 KiB), matching the mod-payload cap. Higher-bandwidth tiers such as chunk streaming define their own caps.
|
||||
pub const MAX_CONTROL_FRAME_LEN: usize = 64 * 1024;
|
||||
|
||||
/// The maximum payload length, in bytes, accepted on a chunk stream (1 MiB). A worst-case fully-modified 32³ chunk serializes to roughly 256 KiB as a sparse `ChunkData` (32 768 edits of a varint index plus a `u16` block), so 1 MiB clears the worst case with comfortable margin while still bounding a malicious or corrupt peer's allocation.
|
||||
pub const MAX_CHUNK_FRAME_LEN: usize = 1024 * 1024;
|
||||
|
||||
/// The maximum number of bytes an unsigned LEB128 varint may occupy for a `u64` value (`ceil(64 / 7)`).
|
||||
const MAX_VARINT_LEN: usize = 10;
|
||||
|
||||
/// Appends `value` to `buf` as an unsigned LEB128 varint.
|
||||
///
|
||||
/// Each byte carries seven value bits in little-endian group order; the high bit (`0x80`) is a continuation flag set on every byte except the last.
|
||||
fn write_varint(value: u64, buf: &mut Vec<u8>) {
|
||||
let mut remaining = value;
|
||||
loop {
|
||||
// Extract the low seven bits of the remaining value.
|
||||
let mut byte = (remaining & 0x7f) as u8;
|
||||
remaining >>= 7;
|
||||
if remaining != 0 {
|
||||
// Further bytes follow, so mark the continuation bit.
|
||||
byte |= 0x80;
|
||||
}
|
||||
buf.push(byte);
|
||||
if remaining == 0 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads an unsigned LEB128 varint from the front of `bytes`, returning the decoded value and the number of bytes consumed.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`NetError::MalformedVarint`] if the encoding exceeds the ten bytes a `u64` may occupy, or [`NetError::UnexpectedEof`] if the buffer ends while the continuation bit is still set.
|
||||
fn read_varint(bytes: &[u8]) -> Result<(u64, usize), NetError> {
|
||||
let mut value: u64 = 0;
|
||||
let mut shift: u32 = 0;
|
||||
for (index, &byte) in bytes.iter().enumerate() {
|
||||
if index >= MAX_VARINT_LEN {
|
||||
return Err(NetError::MalformedVarint);
|
||||
}
|
||||
// Accumulate the seven payload bits at their little-endian position.
|
||||
value |= u64::from(byte & 0x7f) << shift;
|
||||
if byte & 0x80 == 0 {
|
||||
return Ok((value, index + 1));
|
||||
}
|
||||
shift += 7;
|
||||
}
|
||||
// The continuation bit was still set when the buffer ran out.
|
||||
Err(NetError::UnexpectedEof)
|
||||
}
|
||||
|
||||
/// Encodes `msg` as a single length-prefixed `postcard` frame into a freshly allocated buffer.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`NetError::Postcard`] if `msg` fails to serialize.
|
||||
fn encode_frame<T: serde::Serialize>(msg: &T) -> Result<Vec<u8>, NetError> {
|
||||
let payload = postcard::to_stdvec(msg)?;
|
||||
let mut frame = Vec::new();
|
||||
write_varint(payload.len() as u64, &mut frame);
|
||||
frame.extend_from_slice(&payload);
|
||||
Ok(frame)
|
||||
}
|
||||
|
||||
/// Writes one length-prefixed `postcard` frame to a quinn send stream.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`NetError::Postcard`] if `msg` fails to serialize, or [`NetError::Write`] if the send stream rejects the bytes.
|
||||
pub async fn write_frame<T: serde::Serialize>(
|
||||
stream: &mut quinn::SendStream,
|
||||
msg: &T,
|
||||
) -> Result<(), NetError> {
|
||||
let frame = encode_frame(msg)?;
|
||||
stream.write_all(&frame).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Reads one length-prefixed `postcard` frame from a quinn recv stream and decodes it.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`NetError::MalformedVarint`] if the length prefix is overlong, [`NetError::FrameTooLarge`] if the declared length exceeds `max_len`, [`NetError::Read`] if the stream ends before the frame is complete, or [`NetError::Postcard`] if the payload fails to deserialize.
|
||||
pub async fn read_frame<T: serde::de::DeserializeOwned>(
|
||||
stream: &mut quinn::RecvStream,
|
||||
max_len: usize,
|
||||
) -> Result<T, NetError> {
|
||||
// The prefix length is not known in advance, so bytes are pulled one at a time until a byte without the continuation flag is read, then decoded by the shared pure helper.
|
||||
let mut prefix = Vec::with_capacity(MAX_VARINT_LEN);
|
||||
loop {
|
||||
let mut byte = [0u8; 1];
|
||||
stream.read_exact(&mut byte).await?;
|
||||
prefix.push(byte[0]);
|
||||
if byte[0] & 0x80 == 0 {
|
||||
break;
|
||||
}
|
||||
if prefix.len() > MAX_VARINT_LEN {
|
||||
return Err(NetError::MalformedVarint);
|
||||
}
|
||||
}
|
||||
let (len, _consumed) = read_varint(&prefix)?;
|
||||
|
||||
// The declared length is validated before allocating the payload buffer.
|
||||
let checked_len = check_frame_len(len, max_len)?;
|
||||
let mut payload = vec![0u8; checked_len];
|
||||
stream.read_exact(&mut payload).await?;
|
||||
Ok(postcard::from_bytes(&payload)?)
|
||||
}
|
||||
|
||||
/// Validates a declared frame length against `max_len`.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`NetError::FrameTooLarge`] if `len` exceeds `max_len`.
|
||||
fn check_frame_len(len: u64, max_len: usize) -> Result<usize, NetError> {
|
||||
if len > max_len as u64 {
|
||||
return Err(NetError::FrameTooLarge { len, max: max_len });
|
||||
}
|
||||
#[expect(
|
||||
clippy::cast_possible_truncation,
|
||||
reason = "len <= max_len (usize) checked above"
|
||||
)]
|
||||
Ok(len as usize)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tests/codec.rs"]
|
||||
mod tests;
|
||||
151
crates/net/src/endpoint.rs
Normal file
151
crates/net/src/endpoint.rs
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
//! QUIC endpoint construction for client and server.
|
||||
//!
|
||||
//! Builds the `quinn` endpoints and configures ALPN and TLS 1.3. Both endpoints negotiate the `synvael` application protocol; a peer advertising any other ALPN identifier is rejected during the TLS handshake.
|
||||
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use quinn::crypto::rustls::{QuicClientConfig, QuicServerConfig};
|
||||
use quinn::{ClientConfig, Endpoint, IdleTimeout, ServerConfig, TransportConfig, VarInt};
|
||||
use rustls::DigitallySignedStruct;
|
||||
use rustls::SignatureScheme;
|
||||
use rustls::client::danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier};
|
||||
use rustls::pki_types::{CertificateDer, PrivateKeyDer, PrivatePkcs8KeyDer, ServerName, UnixTime};
|
||||
use tracing::debug;
|
||||
|
||||
use crate::error::NetError;
|
||||
|
||||
/// The Application-Layer Protocol Negotiation identifier for the Synvael protocol.
|
||||
pub const ALPN: &[u8] = b"synvael";
|
||||
|
||||
/// Interval between QUIC keep-alive probes, in milliseconds.
|
||||
///
|
||||
/// Kept well below [`MAX_IDLE_TIMEOUT_MS`] so several probes elapse before the idle timeout could fire. Keep-alives are required because chunk delivery deliberately stalls the stream when the client cannot mesh fast enough: during such a flow-control stall no application data flows in either direction, and without a probe the connection would be indistinguishable from a dead peer and closed on the idle timeout.
|
||||
const KEEP_ALIVE_INTERVAL_MS: u32 = 5_000;
|
||||
|
||||
/// Maximum time with no received packets before a connection is considered lost, in milliseconds.
|
||||
const MAX_IDLE_TIMEOUT_MS: u32 = 30_000;
|
||||
|
||||
/// Builds the QUIC transport configuration shared by both endpoints.
|
||||
///
|
||||
/// Enables keep-alive probes and sets an explicit idle timeout; see [`KEEP_ALIVE_INTERVAL_MS`] for why probes are mandatory given the chunk stream's backpressure behaviour. All other transport parameters retain their `quinn` defaults.
|
||||
fn transport_config() -> Arc<TransportConfig> {
|
||||
let mut transport = TransportConfig::default();
|
||||
transport.keep_alive_interval(Some(Duration::from_millis(u64::from(
|
||||
KEEP_ALIVE_INTERVAL_MS,
|
||||
))));
|
||||
// `VarInt::from_u32` is infallible, so no fallible `IdleTimeout::try_from(Duration)` conversion is needed.
|
||||
transport.max_idle_timeout(Some(IdleTimeout::from(VarInt::from_u32(
|
||||
MAX_IDLE_TIMEOUT_MS,
|
||||
))));
|
||||
Arc::new(transport)
|
||||
}
|
||||
|
||||
/// Installs the process-wide default `rustls` `CryptoProvider` if one is not already installed.
|
||||
fn ensure_crypto_provider() {
|
||||
if rustls::crypto::ring::default_provider()
|
||||
.install_default()
|
||||
.is_err()
|
||||
{
|
||||
debug!("rustls crypto provider already installed; reusing existing default");
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds a QUIC server endpoint bound to `bind`, using a freshly generated self-signed certificate and the `synvael` ALPN.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`NetError::Rcgen`] if certificate generation fails, [`NetError::Rustls`] if the TLS configuration cannot be built, [`NetError::NoInitialCipherSuite`] if the configuration lacks a TLS 1.3 cipher suite, or [`NetError::Io`] if the UDP socket cannot be bound.
|
||||
pub fn server_endpoint(bind: SocketAddr) -> Result<Endpoint, NetError> {
|
||||
ensure_crypto_provider();
|
||||
|
||||
// Generate a self-signed certificate for the "localhost" subject. The subject is not validated by the current client verifier and exists only to satisfy certificate structure.
|
||||
let cert = rcgen::generate_simple_self_signed(vec!["localhost".to_owned()])?;
|
||||
let cert_der = cert.cert.der().clone();
|
||||
let key_der = PrivateKeyDer::Pkcs8(PrivatePkcs8KeyDer::from(cert.signing_key.serialize_der()));
|
||||
|
||||
let mut tls_config = rustls::ServerConfig::builder()
|
||||
.with_no_client_auth()
|
||||
.with_single_cert(vec![cert_der], key_der)?;
|
||||
tls_config.alpn_protocols = vec![ALPN.to_vec()];
|
||||
|
||||
let quic_config = QuicServerConfig::try_from(tls_config)?;
|
||||
let mut server_config = ServerConfig::with_crypto(Arc::new(quic_config));
|
||||
server_config.transport_config(transport_config());
|
||||
|
||||
Ok(Endpoint::server(server_config, bind)?)
|
||||
}
|
||||
|
||||
/// Builds a QUIC client endpoint bound to an ephemeral local address, configured with the `synvael` ALPN and a permissive certificate verifier.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`NetError::NoInitialCipherSuite`] if the TLS configuration lacks a TLS 1.3 cipher suite, or [`NetError::Io`] if the local UDP socket cannot be bound.
|
||||
pub fn client_endpoint() -> Result<Endpoint, NetError> {
|
||||
ensure_crypto_provider();
|
||||
|
||||
let mut tls_config = rustls::ClientConfig::builder()
|
||||
.dangerous()
|
||||
.with_custom_certificate_verifier(Arc::new(AcceptAnyServerCert))
|
||||
.with_no_client_auth();
|
||||
tls_config.alpn_protocols = vec![ALPN.to_vec()];
|
||||
|
||||
let quic_config = QuicClientConfig::try_from(tls_config)?;
|
||||
let mut client_config = ClientConfig::new(Arc::new(quic_config));
|
||||
client_config.transport_config(transport_config());
|
||||
|
||||
let mut endpoint = Endpoint::client("0.0.0.0:0".parse().map_err(std::io::Error::other)?)?;
|
||||
endpoint.set_default_client_config(client_config);
|
||||
|
||||
Ok(endpoint)
|
||||
}
|
||||
|
||||
/// A certificate verifier that unconditionally accepts any server certificate, disabling server authentication.
|
||||
// TODO: replace with trust-on-first-use + certificate pinning.
|
||||
#[derive(Debug)]
|
||||
struct AcceptAnyServerCert;
|
||||
|
||||
impl ServerCertVerifier for AcceptAnyServerCert {
|
||||
fn verify_server_cert(
|
||||
&self,
|
||||
_end_entity: &CertificateDer<'_>,
|
||||
_intermediates: &[CertificateDer<'_>],
|
||||
_server_name: &ServerName<'_>,
|
||||
_ocsp_response: &[u8],
|
||||
_now: UnixTime,
|
||||
) -> Result<ServerCertVerified, rustls::Error> {
|
||||
Ok(ServerCertVerified::assertion())
|
||||
}
|
||||
|
||||
fn verify_tls12_signature(
|
||||
&self,
|
||||
_message: &[u8],
|
||||
_cert: &CertificateDer<'_>,
|
||||
_dss: &DigitallySignedStruct,
|
||||
) -> Result<HandshakeSignatureValid, rustls::Error> {
|
||||
Ok(HandshakeSignatureValid::assertion())
|
||||
}
|
||||
|
||||
fn verify_tls13_signature(
|
||||
&self,
|
||||
_message: &[u8],
|
||||
_cert: &CertificateDer<'_>,
|
||||
_dss: &DigitallySignedStruct,
|
||||
) -> Result<HandshakeSignatureValid, rustls::Error> {
|
||||
Ok(HandshakeSignatureValid::assertion())
|
||||
}
|
||||
|
||||
fn supported_verify_schemes(&self) -> Vec<SignatureScheme> {
|
||||
// Advertise the schemes the installed provider can verify, so the shim does not artificially restrict handshake negotiation.
|
||||
rustls::crypto::ring::default_provider()
|
||||
.signature_verification_algorithms
|
||||
.supported_schemes()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tests/endpoint.rs"]
|
||||
mod tests;
|
||||
73
crates/net/src/error.rs
Normal file
73
crates/net/src/error.rs
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
//! Error types for the net crate.
|
||||
|
||||
use thiserror::Error;
|
||||
|
||||
/// Errors produced by the framing codec, endpoint construction, and stream I/O helpers.
|
||||
#[derive(Debug, Error)]
|
||||
pub enum NetError {
|
||||
/// A `postcard` serialization or deserialization operation failed.
|
||||
#[error("postcard codec error: {0}")]
|
||||
Postcard(#[from] postcard::Error),
|
||||
/// An underlying byte-stream I/O operation failed.
|
||||
#[error("i/o error: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
/// A declared frame length exceeded the caller-supplied maximum, indicating a malicious or corrupt peer.
|
||||
#[error("frame length {len} exceeds maximum {max}")]
|
||||
FrameTooLarge {
|
||||
/// The frame length declared by the length prefix, in bytes.
|
||||
len: u64,
|
||||
/// The maximum payload length accepted by the reader, in bytes.
|
||||
max: usize,
|
||||
},
|
||||
/// The stream ended before a complete frame (prefix or payload) had been read.
|
||||
#[error("unexpected end of stream while reading a frame")]
|
||||
UnexpectedEof,
|
||||
/// A varint length prefix was malformed: either overlong or otherwise invalid.
|
||||
#[error("malformed varint length prefix")]
|
||||
MalformedVarint,
|
||||
/// Writing bytes to a quinn send stream failed.
|
||||
#[error("quinn write error: {0}")]
|
||||
Write(#[from] quinn::WriteError),
|
||||
/// Reading an exact number of bytes from a quinn recv stream failed.
|
||||
#[error("quinn read error: {0}")]
|
||||
Read(#[from] quinn::ReadExactError),
|
||||
/// Generation of the self-signed server certificate failed.
|
||||
#[error("certificate generation error: {0}")]
|
||||
Rcgen(#[from] rcgen::Error),
|
||||
/// Construction of the `rustls` TLS configuration failed.
|
||||
#[error("rustls configuration error: {0}")]
|
||||
Rustls(#[from] rustls::Error),
|
||||
/// The `rustls` configuration lacked a TLS 1.3 cipher suite, which QUIC requires.
|
||||
#[error("no initial cipher suite for quic: {0}")]
|
||||
NoInitialCipherSuite(#[from] quinn::crypto::rustls::NoInitialCipherSuite),
|
||||
}
|
||||
|
||||
/// Errors produced while performing the Synvael application handshake.
|
||||
#[derive(Debug, Error)]
|
||||
pub enum HandshakeError {
|
||||
/// The synchronous `quinn` connect call failed before the connection attempt began.
|
||||
#[error("quic connect error: {0}")]
|
||||
Connect(#[from] quinn::ConnectError),
|
||||
/// The QUIC connection failed to establish or was lost during the handshake.
|
||||
#[error("quic connection error: {0}")]
|
||||
Connection(#[from] quinn::ConnectionError),
|
||||
/// A control-stream frame failed to encode, decode, or transfer.
|
||||
#[error("control frame codec error: {0}")]
|
||||
Codec(#[from] NetError),
|
||||
/// The server refused the handshake. Carries the structured reason received (client side) or sent (server side) over the wire.
|
||||
#[error("handshake rejected: {0:?}")]
|
||||
Rejected(shared::protocol::HandshakeReject),
|
||||
/// A control message other than the one expected for this handshake step arrived.
|
||||
#[error("unexpected control message during handshake")]
|
||||
UnexpectedMessage,
|
||||
/// The client's protocol version did not match the server's. Returned locally by the server after it has sent a [`shared::protocol::HandshakeReject`] to the client.
|
||||
#[error("protocol version mismatch: client {client}, server {server}")]
|
||||
VersionMismatch {
|
||||
/// Protocol version advertised by the client in its `ClientHello`.
|
||||
client: u32,
|
||||
/// Protocol version the server was built against (`shared::protocol::PROTOCOL_VERSION`).
|
||||
server: u32,
|
||||
},
|
||||
}
|
||||
174
crates/net/src/handshake.rs
Normal file
174
crates/net/src/handshake.rs
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
//! Synvael application handshake over an established QUIC connection.
|
||||
|
||||
use quinn::{Connection, Incoming, RecvStream, SendStream, VarInt};
|
||||
use shared::protocol::{
|
||||
ClientHello, ControlMessage, Disconnect, HandshakeAck, HandshakeReject, PROTOCOL_VERSION,
|
||||
RejectReason, StreamLayout,
|
||||
};
|
||||
use tracing::{info, warn};
|
||||
|
||||
use crate::codec::{MAX_CONTROL_FRAME_LEN, read_frame, write_frame};
|
||||
use crate::error::HandshakeError;
|
||||
|
||||
/// Application close code used when a peer is rejected during the handshake.
|
||||
const CLOSE_CODE_REJECTED: u32 = 1;
|
||||
|
||||
/// Application close code used for an orderly, graceful disconnect.
|
||||
const CLOSE_CODE_GRACEFUL: u32 = 0;
|
||||
|
||||
/// Upper bound on the wait for a rejected client to read the `HandshakeReject` and close, before the server tears the connection down anyway.
|
||||
const REJECT_DELIVERY_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
|
||||
|
||||
/// A completed client-side handshake: an established connection, the retained control stream, and the server's acceptance parameters.
|
||||
#[derive(Debug)]
|
||||
pub struct Connected {
|
||||
/// The established QUIC connection. Additional streams are opened from it.
|
||||
pub connection: Connection,
|
||||
/// The control stream (stream 0), retained so it can carry chat, commands, and disconnect. Never finished after the handshake.
|
||||
pub control: (SendStream, RecvStream),
|
||||
/// The negotiated session parameters returned by the server.
|
||||
pub ack: HandshakeAck,
|
||||
}
|
||||
|
||||
/// A completed server-side handshake for one connection: the established connection, the retained control stream, and the client's presented identity.
|
||||
#[derive(Debug)]
|
||||
pub struct ServerConnection {
|
||||
/// The established QUIC connection. Additional streams are accepted from it.
|
||||
pub connection: Connection,
|
||||
/// The control stream (stream 0), retained so it can carry chat, commands, and disconnect. Never finished after the handshake.
|
||||
pub control: (SendStream, RecvStream),
|
||||
/// The `ClientHello` the accepted client presented.
|
||||
pub hello: ClientHello,
|
||||
}
|
||||
|
||||
/// Connects to a Synvael server: establishes the QUIC connection, opens the control stream, sends `ClientHello`, and awaits the server's response.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`HandshakeError::Connect`] if the connection attempt cannot be initiated, [`HandshakeError::Connection`] if the QUIC connection fails to establish, [`HandshakeError::Codec`] if the `ClientHello` cannot be written or the reply cannot be read, [`HandshakeError::Rejected`] if the server refuses the handshake, and [`HandshakeError::UnexpectedMessage`] if the server replies with a control message other than `HandshakeAck` or `HandshakeReject`.
|
||||
pub async fn connect(
|
||||
endpoint: &quinn::Endpoint,
|
||||
server_addr: std::net::SocketAddr,
|
||||
server_name: &str,
|
||||
hello: ClientHello,
|
||||
) -> Result<Connected, HandshakeError> {
|
||||
let connection = endpoint.connect(server_addr, server_name)?.await?;
|
||||
|
||||
// The client opens the control stream; the server accepts it. The stream first appears on the server once the `ClientHello` bytes are written below.
|
||||
let (mut send, mut recv) = connection.open_bi().await?;
|
||||
|
||||
write_frame(&mut send, &ControlMessage::ClientHello(hello)).await?;
|
||||
|
||||
match read_frame::<ControlMessage>(&mut recv, MAX_CONTROL_FRAME_LEN).await? {
|
||||
ControlMessage::HandshakeAck(ack) => {
|
||||
info!(
|
||||
protocol_version = ack.protocol_version,
|
||||
server_build = %ack.server_build,
|
||||
"handshake accepted by server"
|
||||
);
|
||||
Ok(Connected {
|
||||
connection,
|
||||
control: (send, recv),
|
||||
ack,
|
||||
})
|
||||
}
|
||||
ControlMessage::HandshakeReject(rej) => {
|
||||
warn!(reason = ?rej.reason, detail = %rej.detail, "handshake rejected by server");
|
||||
Err(HandshakeError::Rejected(rej))
|
||||
}
|
||||
_ => Err(HandshakeError::UnexpectedMessage),
|
||||
}
|
||||
}
|
||||
|
||||
/// Accepts one incoming connection: completes the QUIC handshake, reads the client's `ClientHello`, validates it, and replies with `HandshakeAck` or `HandshakeReject`.
|
||||
///
|
||||
/// On a protocol-version mismatch a [`HandshakeReject`] is sent to the client, the connection is closed with [`CLOSE_CODE_REJECTED`], and [`HandshakeError::VersionMismatch`] is returned locally.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`HandshakeError::Connection`] if the QUIC connection fails to establish or the control stream cannot be accepted, [`HandshakeError::Codec`] if the `ClientHello` cannot be read or a reply cannot be written, [`HandshakeError::UnexpectedMessage`] if the first control message is not a `ClientHello`, and [`HandshakeError::VersionMismatch`] if the client's protocol version does not match the server's.
|
||||
pub async fn accept_connection(
|
||||
incoming: Incoming,
|
||||
server_build: String,
|
||||
tick_rate_hint: u16,
|
||||
) -> Result<ServerConnection, HandshakeError> {
|
||||
let connection = incoming.await?;
|
||||
|
||||
// The client opened the control stream; the server accepts it here.
|
||||
let (mut send, mut recv) = connection.accept_bi().await?;
|
||||
|
||||
let ControlMessage::ClientHello(hello) =
|
||||
read_frame::<ControlMessage>(&mut recv, MAX_CONTROL_FRAME_LEN).await?
|
||||
else {
|
||||
return Err(HandshakeError::UnexpectedMessage);
|
||||
};
|
||||
|
||||
if hello.protocol_version != PROTOCOL_VERSION {
|
||||
let reject = HandshakeReject {
|
||||
reason: RejectReason::ProtocolMismatch,
|
||||
detail: format!(
|
||||
"client protocol version {}, server protocol version {PROTOCOL_VERSION}",
|
||||
hello.protocol_version
|
||||
),
|
||||
upgrade_url: None,
|
||||
};
|
||||
warn!(
|
||||
client = hello.protocol_version,
|
||||
server = PROTOCOL_VERSION,
|
||||
"rejecting client on protocol mismatch"
|
||||
);
|
||||
// Send the rejection, then keep the connection alive until the client has read it and closed. `Connection::close` (and dropping the connection) discards buffered stream data, so an immediate close would race the client's read and lose the reject frame. The wait is bounded so a misbehaving client cannot park the accept task indefinitely.
|
||||
write_frame(&mut send, &ControlMessage::HandshakeReject(reject)).await?;
|
||||
let _ = send.finish();
|
||||
let _ = tokio::time::timeout(REJECT_DELIVERY_TIMEOUT, connection.closed()).await;
|
||||
connection.close(
|
||||
VarInt::from_u32(CLOSE_CODE_REJECTED),
|
||||
b"protocol version mismatch",
|
||||
);
|
||||
return Err(HandshakeError::VersionMismatch {
|
||||
client: hello.protocol_version,
|
||||
server: PROTOCOL_VERSION,
|
||||
});
|
||||
}
|
||||
|
||||
// TODO: validate installed_packs against the world's required packs once the modlist-matching concept lands.
|
||||
let ack = HandshakeAck {
|
||||
protocol_version: PROTOCOL_VERSION,
|
||||
server_build,
|
||||
world_packs: vec![],
|
||||
missing_packs: vec![],
|
||||
stream_layout: StreamLayout::default(),
|
||||
tick_rate_hint,
|
||||
};
|
||||
write_frame(&mut send, &ControlMessage::HandshakeAck(ack)).await?;
|
||||
|
||||
info!(
|
||||
display_name = %hello.player_identity.display_name,
|
||||
"handshake accepted"
|
||||
);
|
||||
|
||||
Ok(ServerConnection {
|
||||
connection,
|
||||
control: (send, recv),
|
||||
hello,
|
||||
})
|
||||
}
|
||||
|
||||
/// Sends a `Disconnect` control message and then closes the connection cleanly.
|
||||
///
|
||||
/// The control stream's send half is borrowed mutably to write the final frame; the connection is then closed with [`CLOSE_CODE_GRACEFUL`]. A failure to write the `Disconnect` frame is logged rather than propagated, since the connection is closed unconditionally afterwards. Both [`Connected`] and [`ServerConnection`] expose their parts as `connection` and `control`, so either can call this with `(&conn.connection, &mut conn.control.0, reason)`.
|
||||
pub async fn graceful_disconnect(connection: &Connection, send: &mut SendStream, reason: &str) {
|
||||
if let Err(error) = write_frame(
|
||||
send,
|
||||
&ControlMessage::Disconnect(Disconnect {
|
||||
reason: reason.to_owned(),
|
||||
}),
|
||||
)
|
||||
.await
|
||||
{
|
||||
warn!(%error, "failed to send disconnect frame; closing connection regardless");
|
||||
}
|
||||
connection.close(VarInt::from_u32(CLOSE_CODE_GRACEFUL), reason.as_bytes());
|
||||
}
|
||||
32
crates/net/src/lib.rs
Normal file
32
crates/net/src/lib.rs
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
//! QUIC transport, connection lifecycle, and wire framing for the Synvael client-server protocol.
|
||||
//!
|
||||
//! This crate owns the asynchronous runtime and the transport dependencies, keeping them out of the lean `shared` protocol crate. Protocol message types live in `shared`; this crate is responsible only for carrying them over the wire.
|
||||
//!
|
||||
//! The synchronous simulation loop (`server`) and windowing loop (`client`) never touch the async runtime directly. They exchange messages with the network over channels, so the async runtime stays confined to this crate.
|
||||
|
||||
pub mod authority;
|
||||
pub mod chunk;
|
||||
pub mod codec;
|
||||
pub mod endpoint;
|
||||
pub mod error;
|
||||
pub mod handshake;
|
||||
pub mod runtime;
|
||||
pub mod stats;
|
||||
|
||||
pub use authority::AuthoritySink;
|
||||
pub use chunk::{ChunkSink, ChunkSubscriber};
|
||||
pub use runtime::{
|
||||
AuthorityStream, ChunkStream, ClientLink, ConnectOutcome, NetworkServer, ServerEvent,
|
||||
connect_in_background,
|
||||
};
|
||||
pub use stats::NetStats;
|
||||
|
||||
/// Default UDP port the server binds and the client connects to when none is configured.
|
||||
// TODO: make the bind address and port configurable through server/client configuration.
|
||||
pub const DEFAULT_PORT: u16 = 25565;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tests/handshake.rs"]
|
||||
mod handshake_tests;
|
||||
368
crates/net/src/runtime.rs
Normal file
368
crates/net/src/runtime.rs
Normal file
|
|
@ -0,0 +1,368 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
//! Threaded `tokio` runtime bridge between the async network and the synchronous simulation.
|
||||
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::{Arc, OnceLock};
|
||||
use std::thread;
|
||||
|
||||
use shared::protocol::authority::AuthorityMessage;
|
||||
use shared::protocol::chunk::{ChunkMessage, ChunkSubscribe};
|
||||
use shared::protocol::{ClientHello, HandshakeAck};
|
||||
use tracing::{info, warn};
|
||||
|
||||
use crate::authority::{AuthoritySink, client_authority_task, server_authority_task};
|
||||
use crate::chunk::{ChunkSink, ChunkSubscriber, chunk_stream_task, client_chunk_task};
|
||||
use crate::endpoint::{client_endpoint, server_endpoint};
|
||||
use crate::error::NetError;
|
||||
use crate::handshake::{ServerConnection, accept_connection, connect};
|
||||
use crate::stats::{self, NetCounters, NetStats};
|
||||
|
||||
/// Channel receiver delivering the outcome of a background client connect: the negotiated [`HandshakeAck`] on success, or a human-readable error string on failure.
|
||||
pub type ConnectOutcome = crossbeam_channel::Receiver<Result<HandshakeAck, String>>;
|
||||
|
||||
/// Bounded receiver of chunks delivered by the server, drained by the UI thread with `try_recv`.
|
||||
pub type ChunkStream = tokio::sync::mpsc::Receiver<ChunkMessage>;
|
||||
|
||||
/// Capacity of the client's chunk-delivery channel, in [`ChunkMessage`]s.
|
||||
// TODO: revisit once meshing moves to a worker pool; the right depth follows the UI's consume rate, so this is a candidate to derive from the meshing budget / view distance in a config layer rather than a hand-set constant.
|
||||
const CHUNK_DELIVERY_CAPACITY: usize = 32;
|
||||
|
||||
/// Bounded receiver of authority-stream messages, drained by the UI thread with `try_recv`.
|
||||
pub type AuthorityStream = tokio::sync::mpsc::Receiver<AuthorityMessage>;
|
||||
|
||||
/// Capacity of the client's authority channel, in messages.
|
||||
///
|
||||
/// Shallow on purpose: the server pushes roughly one message per second, so anything beyond a small backlog is stale by the time the UI would read it. The network task drops rather than blocks when this fills.
|
||||
const AUTHORITY_CAPACITY: usize = 4;
|
||||
|
||||
/// Handles a background client connection exposes to the synchronous UI thread.
|
||||
///
|
||||
/// The network task keeps the QUIC connection alive on its own thread; this bundle is how the winit loop observes the handshake outcome, pushes subscription updates, and drains chunk deliveries, all without touching the async runtime.
|
||||
pub struct ClientLink {
|
||||
/// Handshake outcome, drained once for the negotiated ack or the failure reason.
|
||||
pub handshake: ConnectOutcome,
|
||||
/// Sends subscription updates (center and radius) to the server as the camera moves.
|
||||
pub subscribe: ChunkSubscriber,
|
||||
/// Receives chunk deliveries from the server, drained non-blocking each frame.
|
||||
pub chunks: ChunkStream,
|
||||
/// Receives periodic server-authoritative state, drained non-blocking each frame.
|
||||
pub authority: AuthorityStream,
|
||||
/// The live QUIC connection, published by the network thread once the handshake completes. Held privately so the `quinn` types stay inside this crate; the UI thread reads through [`ClientLink::stats`].
|
||||
connection: Arc<OnceLock<quinn::Connection>>,
|
||||
/// Application-level message counters shared with the chunk task.
|
||||
counters: Arc<NetCounters>,
|
||||
}
|
||||
|
||||
impl ClientLink {
|
||||
/// Snapshots the connection's transport statistics.
|
||||
///
|
||||
/// Safe to call before the handshake completes; the result then reports the disconnected state rather than failing.
|
||||
#[must_use]
|
||||
pub fn stats(&self) -> NetStats {
|
||||
stats::snapshot(self.connection.get(), &self.counters)
|
||||
}
|
||||
}
|
||||
|
||||
/// An event surfaced by the network thread to the synchronous server loop.
|
||||
#[derive(Debug)]
|
||||
pub enum ServerEvent {
|
||||
/// A client completed the Synvael handshake. Carries the stable per-session id, the `ClientHello` it presented, and the sink the simulation loop uses to deliver chunks to this connection's chunk stream.
|
||||
ClientConnected {
|
||||
/// Stable identifier assigned to this session for the lifetime of the connection.
|
||||
id: u64,
|
||||
/// The identity and build parameters the client advertised.
|
||||
hello: ClientHello,
|
||||
/// Outbound handle for delivering [`shared::protocol::chunk::ChunkMessage`]s to this client. The simulation loop retains it, keyed by `id`, until the matching [`ServerEvent::ClientDisconnected`].
|
||||
chunks: ChunkSink,
|
||||
/// Outbound handle for pushing [`shared::protocol::authority::AuthorityMessage`]s to this client, retained alongside `chunks` for the same lifetime.
|
||||
authority: AuthoritySink,
|
||||
},
|
||||
/// A previously connected client's session ended.
|
||||
ClientDisconnected {
|
||||
/// Identifier of the session that ended, matching the earlier [`ServerEvent::ClientConnected`].
|
||||
id: u64,
|
||||
/// Human-readable description of why the connection closed.
|
||||
reason: String,
|
||||
},
|
||||
/// A connected client updated its chunk subscription: the initial subscribe on connect, or a later update as its center chunk moves.
|
||||
ChunkSubscribe {
|
||||
/// Identifier of the session that sent the subscription, matching its [`ServerEvent::ClientConnected`].
|
||||
id: u64,
|
||||
/// The center and radius the client wants resident.
|
||||
request: ChunkSubscribe,
|
||||
},
|
||||
}
|
||||
|
||||
/// Handle to the background networking thread and its owned `tokio` runtime.
|
||||
#[derive(Debug)]
|
||||
pub struct NetworkServer {
|
||||
/// Events produced by the accept loop, drained by the synchronous simulation thread.
|
||||
events: crossbeam_channel::Receiver<ServerEvent>,
|
||||
/// Shutdown signal. Dropping this sender resolves the accept loop's receiver and breaks the loop.
|
||||
shutdown: Option<tokio::sync::oneshot::Sender<()>>,
|
||||
/// Join handle for the network thread, awaited on drop for an orderly shutdown.
|
||||
thread: Option<thread::JoinHandle<()>>,
|
||||
}
|
||||
|
||||
impl NetworkServer {
|
||||
/// Spawns a dedicated network thread, builds a current-thread `tokio` runtime on it, binds a QUIC server endpoint on `bind`, and runs the accept loop.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`NetError::Io`] if the runtime cannot be built, the network thread cannot be spawned, or the thread exits before reporting a bound address, and any error from [`server_endpoint`] if the endpoint cannot be constructed or bound.
|
||||
pub fn spawn(
|
||||
bind: SocketAddr,
|
||||
server_build: String,
|
||||
tick_rate_hint: u16,
|
||||
) -> Result<(Self, SocketAddr), NetError> {
|
||||
let (events_tx, events_rx) = crossbeam_channel::unbounded();
|
||||
let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel();
|
||||
// Reports the bound address (or the error that prevented binding) back to this thread, so `spawn` can surface it synchronously.
|
||||
let (ready_tx, ready_rx) = crossbeam_channel::bounded::<Result<SocketAddr, NetError>>(1);
|
||||
|
||||
let thread = thread::Builder::new()
|
||||
.name("net-server".to_owned())
|
||||
.spawn(move || {
|
||||
let runtime = match tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
{
|
||||
Ok(runtime) => runtime,
|
||||
Err(error) => {
|
||||
let _ = ready_tx.send(Err(NetError::Io(error)));
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
runtime.block_on(async move {
|
||||
// The endpoint is constructed inside the runtime context because `quinn` binds its driver task onto the current runtime.
|
||||
let endpoint = match server_endpoint(bind) {
|
||||
Ok(endpoint) => endpoint,
|
||||
Err(error) => {
|
||||
let _ = ready_tx.send(Err(error));
|
||||
return;
|
||||
}
|
||||
};
|
||||
let local_addr = match endpoint.local_addr() {
|
||||
Ok(addr) => addr,
|
||||
Err(error) => {
|
||||
let _ = ready_tx.send(Err(NetError::Io(error)));
|
||||
return;
|
||||
}
|
||||
};
|
||||
// If the caller has already given up, there is nothing to serve.
|
||||
if ready_tx.send(Ok(local_addr)).is_err() {
|
||||
return;
|
||||
}
|
||||
|
||||
accept_loop(
|
||||
endpoint,
|
||||
events_tx,
|
||||
shutdown_rx,
|
||||
server_build,
|
||||
tick_rate_hint,
|
||||
)
|
||||
.await;
|
||||
});
|
||||
})
|
||||
.map_err(NetError::Io)?;
|
||||
|
||||
let local_addr = match ready_rx.recv() {
|
||||
Ok(Ok(addr)) => addr,
|
||||
Ok(Err(error)) => return Err(error),
|
||||
Err(_) => {
|
||||
return Err(NetError::Io(std::io::Error::other(
|
||||
"network thread exited before reporting a bound address",
|
||||
)));
|
||||
}
|
||||
};
|
||||
|
||||
Ok((
|
||||
Self {
|
||||
events: events_rx,
|
||||
shutdown: Some(shutdown_tx),
|
||||
thread: Some(thread),
|
||||
},
|
||||
local_addr,
|
||||
))
|
||||
}
|
||||
|
||||
/// Drains every [`ServerEvent`] currently queued, without blocking.
|
||||
pub fn poll_events(&self) -> impl Iterator<Item = ServerEvent> + '_ {
|
||||
self.events.try_iter()
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for NetworkServer {
|
||||
fn drop(&mut self) {
|
||||
// Dropping the sender resolves the accept loop's shutdown receiver, breaking the loop and letting `block_on` return so the thread unwinds and the runtime is dropped.
|
||||
self.shutdown.take();
|
||||
if let Some(thread) = self.thread.take() {
|
||||
let _ = thread.join();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Accepts incoming QUIC connections until the endpoint stops yielding them or a shutdown is signalled, spawning one handshake task per connection.
|
||||
async fn accept_loop(
|
||||
endpoint: quinn::Endpoint,
|
||||
events: crossbeam_channel::Sender<ServerEvent>,
|
||||
mut shutdown: tokio::sync::oneshot::Receiver<()>,
|
||||
server_build: String,
|
||||
tick_rate_hint: u16,
|
||||
) {
|
||||
// Session ids are handed out sequentially; the accept loop is the sole assigner, so a plain counter suffices.
|
||||
let mut next_id: u64 = 0;
|
||||
loop {
|
||||
tokio::select! {
|
||||
incoming = endpoint.accept() => {
|
||||
let Some(incoming) = incoming else { break };
|
||||
let id = next_id;
|
||||
next_id += 1;
|
||||
tokio::spawn(handle_connection(
|
||||
incoming,
|
||||
id,
|
||||
events.clone(),
|
||||
server_build.clone(),
|
||||
tick_rate_hint,
|
||||
));
|
||||
}
|
||||
// Resolves when the `NetworkServer` handle is dropped (sender gone) or an explicit signal is sent.
|
||||
_ = &mut shutdown => break,
|
||||
}
|
||||
}
|
||||
info!("network accept loop shutting down");
|
||||
}
|
||||
|
||||
/// Performs the handshake for one incoming connection and, on success, reports connect and disconnect events for its session.
|
||||
async fn handle_connection(
|
||||
incoming: quinn::Incoming,
|
||||
id: u64,
|
||||
events: crossbeam_channel::Sender<ServerEvent>,
|
||||
server_build: String,
|
||||
tick_rate_hint: u16,
|
||||
) {
|
||||
match accept_connection(incoming, server_build, tick_rate_hint).await {
|
||||
Ok(ServerConnection {
|
||||
connection, hello, ..
|
||||
}) => {
|
||||
// The outbound channels bridge the sync simulation loop to this connection's stream tasks; the sinks are handed to the loop via the connect event.
|
||||
let (chunk_tx, chunk_rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
let (authority_tx, authority_rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
// If the receiver is gone the server is shutting down; drop the connection silently.
|
||||
if events
|
||||
.send(ServerEvent::ClientConnected {
|
||||
id,
|
||||
hello,
|
||||
chunks: ChunkSink::new(chunk_tx),
|
||||
authority: AuthoritySink::new(authority_tx),
|
||||
})
|
||||
.is_err()
|
||||
{
|
||||
return;
|
||||
}
|
||||
// Each pump runs on its own task so the connection-close wait below does not block either.
|
||||
tokio::spawn(chunk_stream_task(
|
||||
connection.clone(),
|
||||
id,
|
||||
events.clone(),
|
||||
chunk_rx,
|
||||
));
|
||||
tokio::spawn(server_authority_task(connection.clone(), id, authority_rx));
|
||||
let reason = connection.closed().await;
|
||||
let _ = events.send(ServerEvent::ClientDisconnected {
|
||||
id,
|
||||
reason: reason.to_string(),
|
||||
});
|
||||
}
|
||||
Err(error) => {
|
||||
warn!(%error, id, "connection handshake failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs a connect and Synvael handshake against `server_addr` on a background `tokio` thread, then pumps the chunk stream, returning the handles the UI thread uses to observe and drive the connection.
|
||||
///
|
||||
/// The returned [`ClientLink`] is available immediately; its channels buffer until the handshake completes and the chunk task starts. A handshake failure is reported on `handshake` and leaves the subscribe and chunk channels inert.
|
||||
#[must_use]
|
||||
pub fn connect_in_background(server_addr: SocketAddr, hello: ClientHello) -> ClientLink {
|
||||
let (outcome_tx, outcome_rx) = crossbeam_channel::bounded(1);
|
||||
// Retained so a failure to spawn the thread can still be reported to the caller.
|
||||
let spawn_err_tx = outcome_tx.clone();
|
||||
// Published by the network thread once the handshake succeeds, so the UI thread can read `quinn`'s own connection statistics without owning the connection.
|
||||
let connection = Arc::new(OnceLock::new());
|
||||
let task_connection = Arc::clone(&connection);
|
||||
let counters = Arc::new(NetCounters::default());
|
||||
let task_counters = Arc::clone(&counters);
|
||||
// Subscription updates flow UI -> network (sync send, async recv); chunk deliveries flow network -> UI (async send, sync try_recv).
|
||||
let (subscribe_tx, subscribe_rx) = tokio::sync::mpsc::unbounded_channel::<ChunkSubscribe>();
|
||||
// The delivery channel is bounded so a slow (e.g. debug-build) UI thread applies backpressure to the network task instead of letting undelivered chunks accumulate without limit.
|
||||
let (chunks_tx, chunks_rx) =
|
||||
tokio::sync::mpsc::channel::<ChunkMessage>(CHUNK_DELIVERY_CAPACITY);
|
||||
let (authority_tx, authority_rx) =
|
||||
tokio::sync::mpsc::channel::<AuthorityMessage>(AUTHORITY_CAPACITY);
|
||||
|
||||
let spawned = thread::Builder::new()
|
||||
.name("net-client".to_owned())
|
||||
.spawn(move || {
|
||||
let runtime = match tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
{
|
||||
Ok(runtime) => runtime,
|
||||
Err(error) => {
|
||||
let _ = outcome_tx.send(Err(format!("failed to build tokio runtime: {error}")));
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
runtime.block_on(async move {
|
||||
let endpoint = match client_endpoint() {
|
||||
Ok(endpoint) => endpoint,
|
||||
Err(error) => {
|
||||
let _ = outcome_tx.send(Err(error.to_string()));
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// The server name matches the self-signed certificate's subject; the current verifier accepts any certificate regardless.
|
||||
match connect(&endpoint, server_addr, "localhost", hello).await {
|
||||
Ok(connected) => {
|
||||
if outcome_tx.send(Ok(connected.ack.clone())).is_err() {
|
||||
return;
|
||||
}
|
||||
// The cell is written exactly once, here; a failure would mean a second handshake on one link, which cannot occur.
|
||||
let _ = task_connection.set(connected.connection.clone());
|
||||
// Pump both streams concurrently on this thread until the UI drops its handles or the server closes the connection. `join!` rather than `select!`: neither stream ending is a reason to abandon the other mid-frame.
|
||||
tokio::join!(
|
||||
client_chunk_task(
|
||||
connected.connection.clone(),
|
||||
subscribe_rx,
|
||||
chunks_tx,
|
||||
&task_counters,
|
||||
),
|
||||
client_authority_task(connected.connection, authority_tx),
|
||||
);
|
||||
warn!("server connection closed");
|
||||
}
|
||||
Err(error) => {
|
||||
let _ = outcome_tx.send(Err(error.to_string()));
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
if let Err(error) = spawned {
|
||||
let _ = spawn_err_tx.send(Err(format!("failed to spawn network thread: {error}")));
|
||||
}
|
||||
|
||||
ClientLink {
|
||||
handshake: outcome_rx,
|
||||
subscribe: ChunkSubscriber::new(subscribe_tx),
|
||||
chunks: chunks_rx,
|
||||
authority: authority_rx,
|
||||
connection,
|
||||
counters,
|
||||
}
|
||||
}
|
||||
103
crates/net/src/stats.rs
Normal file
103
crates/net/src/stats.rs
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
//! Transport statistics for a client connection.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
/// Application-level message counters, incremented by the client's chunk task and read by the UI thread.
|
||||
///
|
||||
/// Held behind an [`Arc`] and mutated with relaxed atomics: each counter is independent, nothing else is ordered against them, and a reader that observes a slightly stale value is reporting a diagnostic figure, not making a decision.
|
||||
#[derive(Debug, Default)]
|
||||
pub(crate) struct NetCounters {
|
||||
/// Chunk deliveries received from the server.
|
||||
chunks_received: AtomicU64,
|
||||
/// Chunk drop notifications received from the server.
|
||||
drops_received: AtomicU64,
|
||||
/// Subscription updates written to the server.
|
||||
subscribes_sent: AtomicU64,
|
||||
}
|
||||
|
||||
impl NetCounters {
|
||||
/// Records one received chunk delivery.
|
||||
pub(crate) fn record_chunk(&self) {
|
||||
self.chunks_received.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Records one received chunk drop notification.
|
||||
pub(crate) fn record_drop(&self) {
|
||||
self.drops_received.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Records one subscription update written to the server.
|
||||
pub(crate) fn record_subscribe(&self) {
|
||||
self.subscribes_sent.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
/// A snapshot of one client connection's transport state.
|
||||
///
|
||||
/// Every field is a value copied at the moment of the call. A snapshot taken before the handshake completes reports `connected == false` and zeroes throughout, which is a meaningful state rather than missing data.
|
||||
#[derive(Copy, Clone, Debug, Default, PartialEq)]
|
||||
pub struct NetStats {
|
||||
/// Whether a QUIC connection is currently established.
|
||||
pub connected: bool,
|
||||
/// Bytes carried in UDP datagrams sent on this connection, including QUIC framing and retransmissions.
|
||||
pub bytes_sent: u64,
|
||||
/// Bytes carried in UDP datagrams received on this connection, including QUIC framing.
|
||||
pub bytes_received: u64,
|
||||
/// UDP datagrams sent on this connection.
|
||||
pub datagrams_sent: u64,
|
||||
/// UDP datagrams received on this connection.
|
||||
pub datagrams_received: u64,
|
||||
/// Chunk deliveries received since the connection was established.
|
||||
pub chunks_received: u64,
|
||||
/// Chunk drop notifications received since the connection was established.
|
||||
pub drops_received: u64,
|
||||
/// Subscription updates written to the server since the connection was established.
|
||||
pub subscribes_sent: u64,
|
||||
/// The QUIC stack's current round-trip-time estimate, in milliseconds.
|
||||
pub rtt_ms: f32,
|
||||
/// Packets the congestion controller has declared lost on the current path.
|
||||
pub lost_packets: u64,
|
||||
/// Current congestion window, in bytes: the ceiling on data in flight.
|
||||
pub congestion_window: u64,
|
||||
/// Largest UDP payload the current path is known to carry, in bytes, as discovered by path MTU probing.
|
||||
pub path_mtu: u16,
|
||||
}
|
||||
|
||||
/// Reads a connection's transport statistics, or reports the disconnected state.
|
||||
///
|
||||
/// `connection` is [`None`] until the handshake completes and after the connection closes.
|
||||
pub(crate) fn snapshot(
|
||||
connection: Option<&quinn::Connection>,
|
||||
counters: &Arc<NetCounters>,
|
||||
) -> NetStats {
|
||||
let mut stats = NetStats {
|
||||
chunks_received: counters.chunks_received.load(Ordering::Relaxed),
|
||||
drops_received: counters.drops_received.load(Ordering::Relaxed),
|
||||
subscribes_sent: counters.subscribes_sent.load(Ordering::Relaxed),
|
||||
..NetStats::default()
|
||||
};
|
||||
|
||||
let Some(connection) = connection else {
|
||||
return stats;
|
||||
};
|
||||
|
||||
let quic = connection.stats();
|
||||
// A connection handle outlives the connection itself; a close reason is how a torn-down connection distinguishes itself from a live one, and its final counters stay readable either way.
|
||||
stats.connected = connection.close_reason().is_none();
|
||||
stats.bytes_sent = quic.udp_tx.bytes;
|
||||
stats.bytes_received = quic.udp_rx.bytes;
|
||||
stats.datagrams_sent = quic.udp_tx.datagrams;
|
||||
stats.datagrams_received = quic.udp_rx.datagrams;
|
||||
stats.rtt_ms = quic.path.rtt.as_secs_f32() * 1000.0;
|
||||
stats.lost_packets = quic.path.lost_packets;
|
||||
stats.congestion_window = quic.path.cwnd;
|
||||
stats.path_mtu = quic.path.current_mtu;
|
||||
stats
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tests/stats.rs"]
|
||||
mod tests;
|
||||
94
crates/net/src/tests/chunk.rs
Normal file
94
crates/net/src/tests/chunk.rs
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
//! Loopback integration test for the chunk-stream transport.
|
||||
//!
|
||||
//! Binds a real QUIC server endpoint, completes the handshake, and drives the server-side [`chunk_stream_task`] end-to-end: a client-sent `ChunkSubscribe` must surface on the simulation-loop events channel as [`ServerEvent::ChunkSubscribe`], and a `ChunkMessage` pushed through the [`ChunkSink`] must be received by the client on the chunk stream.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::chunk::{ChunkSink, chunk_stream_task};
|
||||
use crate::codec::{MAX_CHUNK_FRAME_LEN, read_frame, write_frame};
|
||||
use crate::endpoint::{client_endpoint, server_endpoint};
|
||||
use crate::handshake::{accept_connection, connect};
|
||||
use crate::runtime::ServerEvent;
|
||||
use shared::protocol::chunk::{ChunkMessage, ChunkSubscribe};
|
||||
use shared::protocol::{ClientHello, FeatureFlags, PROTOCOL_VERSION, PlayerIdentity};
|
||||
use shared::world::{ChunkData, ChunkPos};
|
||||
|
||||
/// Builds a minimal `ClientHello` advertising the current protocol version.
|
||||
fn hello(display_name: &str) -> ClientHello {
|
||||
ClientHello {
|
||||
protocol_version: PROTOCOL_VERSION,
|
||||
client_build: "synvael-client-test".to_owned(),
|
||||
player_identity: PlayerIdentity {
|
||||
display_name: display_name.to_owned(),
|
||||
},
|
||||
installed_packs: vec![],
|
||||
requested_features: FeatureFlags(0),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn chunk_subscribe_and_delivery_round_trip()
|
||||
-> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let server = server_endpoint("127.0.0.1:0".parse()?)?;
|
||||
let server_addr = server.local_addr()?;
|
||||
|
||||
// Stand in for the simulation loop's channels: the events channel the task forwards subscriptions to, and the outbound sink it drains deliveries from.
|
||||
let (events_tx, events_rx) = crossbeam_channel::unbounded::<ServerEvent>();
|
||||
let (chunk_tx, chunk_rx) = tokio::sync::mpsc::unbounded_channel::<ChunkMessage>();
|
||||
let sink = ChunkSink::new(chunk_tx);
|
||||
|
||||
// Server side: accept one connection, complete the handshake, then run the chunk pump until the client closes.
|
||||
let server_task = tokio::spawn(async move {
|
||||
let incoming = server.accept().await.ok_or("server endpoint closed")?;
|
||||
let conn = accept_connection(incoming, "synvael-server-test".to_owned(), 20).await?;
|
||||
chunk_stream_task(conn.connection, 7, events_tx, chunk_rx).await;
|
||||
Ok::<_, Box<dyn std::error::Error + Send + Sync>>(())
|
||||
});
|
||||
|
||||
// Client side: connect, then open the chunk stream and send a subscription.
|
||||
let client = client_endpoint()?;
|
||||
let connected = connect(&client, server_addr, "localhost", hello("Tester")).await?;
|
||||
let (mut client_send, mut client_recv) = connected.connection.open_bi().await?;
|
||||
|
||||
let subscribe = ChunkSubscribe {
|
||||
center: ChunkPos::new(1, 2, 3),
|
||||
radius: 4,
|
||||
};
|
||||
write_frame(&mut client_send, &subscribe).await?;
|
||||
|
||||
// The task must forward the subscription to the events channel. The crossbeam receiver is blocking, so it is polled on a blocking thread to avoid stalling the runtime.
|
||||
let event = tokio::task::spawn_blocking(move || {
|
||||
events_rx
|
||||
.recv_timeout(Duration::from_secs(5))
|
||||
.map(|e| (e, events_rx))
|
||||
})
|
||||
.await?;
|
||||
let (event, events_rx) = event?;
|
||||
match event {
|
||||
ServerEvent::ChunkSubscribe { id, request } => {
|
||||
assert_eq!(id, 7, "the subscribe must carry the session id");
|
||||
assert_eq!(request, subscribe, "the subscribe must round-trip intact");
|
||||
}
|
||||
other => return Err(format!("expected ChunkSubscribe, got {other:?}").into()),
|
||||
}
|
||||
|
||||
// The simulation loop hands a chunk back through the sink; the client must receive it on the stream.
|
||||
let data = ChunkData::new(ChunkPos::new(1, 2, 3), 0);
|
||||
let delivered = ChunkMessage::Chunk {
|
||||
pos: ChunkPos::new(1, 2, 3),
|
||||
data: data.clone(),
|
||||
};
|
||||
sink.send(delivered.clone());
|
||||
|
||||
let received = read_frame::<ChunkMessage>(&mut client_recv, MAX_CHUNK_FRAME_LEN).await?;
|
||||
assert_eq!(received, delivered, "the chunk must round-trip intact");
|
||||
|
||||
// Close the client so the server task's pump ends and the endpoint winds down cleanly.
|
||||
drop(events_rx);
|
||||
drop(sink);
|
||||
drop(connected);
|
||||
server_task.await??;
|
||||
Ok(())
|
||||
}
|
||||
118
crates/net/src/tests/codec.rs
Normal file
118
crates/net/src/tests/codec.rs
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
use super::*;
|
||||
use shared::protocol::{
|
||||
ClientHello, ControlMessage, FeatureFlags, PROTOCOL_VERSION, PlayerIdentity,
|
||||
};
|
||||
|
||||
/// Round-trips a set of boundary values through the varint codec, asserting both the decoded value and the exact byte length consumed.
|
||||
#[test]
|
||||
fn varint_round_trip_boundaries() -> Result<(), NetError> {
|
||||
let cases = [
|
||||
0,
|
||||
1,
|
||||
127,
|
||||
128,
|
||||
16_383,
|
||||
16_384,
|
||||
u64::from(u32::MAX),
|
||||
u64::MAX,
|
||||
];
|
||||
for value in cases {
|
||||
let mut buf = Vec::new();
|
||||
write_varint(value, &mut buf);
|
||||
let (decoded, consumed) = read_varint(&buf)?;
|
||||
assert_eq!(decoded, value, "decoded value mismatch");
|
||||
assert_eq!(
|
||||
consumed,
|
||||
buf.len(),
|
||||
"consumed length must equal encoded length"
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A frame encodes as `[varint payload length][payload]`, and decoding the prefix recovers exactly the payload byte count.
|
||||
#[test]
|
||||
fn encode_frame_prefixes_payload_length() -> Result<(), NetError> {
|
||||
let msg = ControlMessage::ClientHello(ClientHello {
|
||||
protocol_version: PROTOCOL_VERSION,
|
||||
client_build: "synvael-client-0.1.0".to_string(),
|
||||
player_identity: PlayerIdentity {
|
||||
display_name: "Player1".to_string(),
|
||||
},
|
||||
installed_packs: Vec::new(),
|
||||
requested_features: FeatureFlags(0),
|
||||
});
|
||||
let payload = postcard::to_stdvec(&msg)?;
|
||||
let frame = encode_frame(&msg)?;
|
||||
|
||||
let (declared_len, prefix_bytes) = read_varint(&frame)?;
|
||||
assert_eq!(
|
||||
declared_len,
|
||||
payload.len() as u64,
|
||||
"prefix must equal payload length"
|
||||
);
|
||||
assert_eq!(
|
||||
&frame[prefix_bytes..],
|
||||
payload.as_slice(),
|
||||
"payload bytes must follow the prefix unchanged"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Confirms the documented one/two-byte boundary encodings so a regression in the continuation logic is caught directly.
|
||||
#[test]
|
||||
fn varint_boundary_lengths() {
|
||||
let mut buf = Vec::new();
|
||||
write_varint(127, &mut buf);
|
||||
assert_eq!(buf.len(), 1, "127 must encode in a single byte");
|
||||
|
||||
buf.clear();
|
||||
write_varint(128, &mut buf);
|
||||
assert_eq!(buf.len(), 2, "128 must encode in two bytes");
|
||||
}
|
||||
|
||||
/// A varint whose final byte still sets the continuation bit is a truncated buffer and must error rather than panic.
|
||||
#[test]
|
||||
fn varint_truncated_is_error() {
|
||||
// Two bytes both flagged as "continued", with no terminating byte.
|
||||
let truncated = [0x80u8, 0x80u8];
|
||||
assert!(
|
||||
read_varint(&truncated).is_err(),
|
||||
"truncated varint must return an error"
|
||||
);
|
||||
}
|
||||
|
||||
/// An encoding longer than the ten bytes a `u64` can occupy is rejected as overlong rather than silently accepted.
|
||||
#[test]
|
||||
fn varint_overlong_is_error() {
|
||||
// Eleven continuation bytes followed by a terminator exceeds the u64 limit.
|
||||
let overlong = [0x80u8; 11];
|
||||
assert!(
|
||||
read_varint(&overlong).is_err(),
|
||||
"overlong varint must return an error"
|
||||
);
|
||||
}
|
||||
|
||||
/// A declared length within the cap is accepted; one exceeding it is rejected as `FrameTooLarge` before any allocation.
|
||||
#[test]
|
||||
fn frame_len_bound_is_enforced() {
|
||||
assert_eq!(
|
||||
check_frame_len(64, 128).ok(),
|
||||
Some(64),
|
||||
"a length within the cap is accepted"
|
||||
);
|
||||
assert_eq!(
|
||||
check_frame_len(128, 128).ok(),
|
||||
Some(128),
|
||||
"a length equal to the cap is accepted"
|
||||
);
|
||||
assert!(
|
||||
matches!(
|
||||
check_frame_len(129, 128),
|
||||
Err(NetError::FrameTooLarge { len: 129, max: 128 })
|
||||
),
|
||||
"a length over the cap must be rejected",
|
||||
);
|
||||
}
|
||||
24
crates/net/src/tests/endpoint.rs
Normal file
24
crates/net/src/tests/endpoint.rs
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn alpn_identifier_is_synvael() {
|
||||
assert_eq!(ALPN, b"synvael");
|
||||
}
|
||||
|
||||
// A tokio runtime is required because `quinn::Endpoint` spawns its driver task on construction.
|
||||
#[tokio::test]
|
||||
async fn server_endpoint_constructs_and_binds() -> Result<(), NetError> {
|
||||
let bind = "127.0.0.1:0".parse().map_err(std::io::Error::other)?;
|
||||
let endpoint = server_endpoint(bind)?;
|
||||
// A concrete port is assigned once the UDP socket is bound.
|
||||
assert_ne!(endpoint.local_addr()?.port(), 0, "socket must bind a port");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn client_endpoint_constructs_and_binds() -> Result<(), NetError> {
|
||||
client_endpoint()?;
|
||||
Ok(())
|
||||
}
|
||||
102
crates/net/src/tests/handshake.rs
Normal file
102
crates/net/src/tests/handshake.rs
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
//! Loopback integration tests for the Synvael application handshake.
|
||||
//!
|
||||
//! Each test binds a real QUIC server endpoint on `127.0.0.1:0`, reads the OS-assigned port, and drives a client through the full `ClientHello` -> `HandshakeAck` / `HandshakeReject` exchange, exercising the async `read_frame`/`write_frame` path end-to-end.
|
||||
|
||||
use crate::endpoint::{client_endpoint, server_endpoint};
|
||||
use crate::error::HandshakeError;
|
||||
use crate::handshake::{accept_connection, connect};
|
||||
use shared::protocol::{ClientHello, FeatureFlags, PROTOCOL_VERSION, PlayerIdentity, RejectReason};
|
||||
|
||||
/// Builds a `ClientHello` for `display_name` advertising `protocol_version`.
|
||||
fn hello(display_name: &str, protocol_version: u32) -> ClientHello {
|
||||
ClientHello {
|
||||
protocol_version,
|
||||
client_build: "synvael-client-test".to_owned(),
|
||||
player_identity: PlayerIdentity {
|
||||
display_name: display_name.to_owned(),
|
||||
},
|
||||
installed_packs: vec![],
|
||||
requested_features: FeatureFlags(0),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn happy_path_completes_handshake() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let server = server_endpoint("127.0.0.1:0".parse()?)?;
|
||||
let server_addr = server.local_addr()?;
|
||||
|
||||
// Accept exactly one connection on the server, returning the observed identity. The connection is held open until the client closes it, so the ack frame is reliably delivered before teardown.
|
||||
let server_task = tokio::spawn(async move {
|
||||
let incoming = server.accept().await.ok_or("server endpoint closed")?;
|
||||
let conn = accept_connection(incoming, "synvael-server-test".to_owned(), 20).await?;
|
||||
let name = conn.hello.player_identity.display_name.clone();
|
||||
conn.connection.closed().await;
|
||||
Ok::<_, Box<dyn std::error::Error + Send + Sync>>(name)
|
||||
});
|
||||
|
||||
let client = client_endpoint()?;
|
||||
let connected = connect(
|
||||
&client,
|
||||
server_addr,
|
||||
"localhost",
|
||||
hello("Tester", PROTOCOL_VERSION),
|
||||
)
|
||||
.await?;
|
||||
|
||||
assert_eq!(
|
||||
connected.ack.protocol_version, PROTOCOL_VERSION,
|
||||
"server must ack with the matching protocol version"
|
||||
);
|
||||
|
||||
// Close the client connection so the server's `closed()` wait resolves.
|
||||
drop(connected);
|
||||
let observed_name = server_task.await??;
|
||||
assert_eq!(
|
||||
observed_name, "Tester",
|
||||
"server must observe the client's display name"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn version_mismatch_is_rejected() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let server = server_endpoint("127.0.0.1:0".parse()?)?;
|
||||
let server_addr = server.local_addr()?;
|
||||
|
||||
let server_task = tokio::spawn(async move {
|
||||
let incoming = server.accept().await.ok_or("server endpoint closed")?;
|
||||
// The server is expected to return VersionMismatch after sending the reject.
|
||||
let result = accept_connection(incoming, "synvael-server-test".to_owned(), 20).await;
|
||||
Ok::<_, Box<dyn std::error::Error + Send + Sync>>(result.is_err())
|
||||
});
|
||||
|
||||
let client = client_endpoint()?;
|
||||
let result = connect(
|
||||
&client,
|
||||
server_addr,
|
||||
"localhost",
|
||||
hello("Tester", PROTOCOL_VERSION + 1),
|
||||
)
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Err(HandshakeError::Rejected(rej)) => {
|
||||
assert_eq!(
|
||||
rej.reason,
|
||||
RejectReason::ProtocolMismatch,
|
||||
"rejection must cite a protocol mismatch"
|
||||
);
|
||||
}
|
||||
other => return Err(format!("expected a rejection, got {other:?}").into()),
|
||||
}
|
||||
|
||||
assert!(
|
||||
server_task.await??,
|
||||
"server must return an error on mismatch"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
31
crates/net/src/tests/stats.rs
Normal file
31
crates/net/src/tests/stats.rs
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
//! Unit tests for the transport statistics snapshot.
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn snapshot_without_a_connection_reports_the_disconnected_state() {
|
||||
let counters = Arc::new(NetCounters::default());
|
||||
let stats = snapshot(None, &counters);
|
||||
|
||||
assert!(!stats.connected);
|
||||
assert_eq!(stats, NetStats::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn counters_are_reported_before_a_connection_exists() {
|
||||
let counters = Arc::new(NetCounters::default());
|
||||
counters.record_chunk();
|
||||
counters.record_chunk();
|
||||
counters.record_drop();
|
||||
counters.record_subscribe();
|
||||
|
||||
let stats = snapshot(None, &counters);
|
||||
assert_eq!(stats.chunks_received, 2);
|
||||
assert_eq!(stats.drops_received, 1);
|
||||
assert_eq!(stats.subscribes_sent, 1);
|
||||
// Transport figures stay zero: they come from the QUIC stack, which has nothing to report yet.
|
||||
assert_eq!(stats.bytes_sent, 0);
|
||||
assert_eq!(stats.bytes_received, 0);
|
||||
}
|
||||
23
crates/renderer/Cargo.toml
Normal file
23
crates/renderer/Cargo.toml
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
[package]
|
||||
name = "renderer"
|
||||
license.workspace = true
|
||||
authors.workspace = true
|
||||
edition.workspace = true
|
||||
version.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[build-dependencies]
|
||||
shaderc = "0.10.1"
|
||||
|
||||
[dependencies]
|
||||
ash = "0.38.0"
|
||||
ash-window.workspace = true
|
||||
raw-window-handle.workspace = true
|
||||
thiserror.workspace = true
|
||||
tracing.workspace = true
|
||||
gpu-allocator = "0.28.0"
|
||||
bytemuck.workspace = true
|
||||
glam.workspace = true
|
||||
shared = { path = "../shared" }
|
||||
100
crates/renderer/build.rs
Normal file
100
crates/renderer/build.rs
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
//! Compiles the crate's GLSL shader sources to SPIR-V at build time.
|
||||
//!
|
||||
//! The resulting modules are written into `OUT_DIR` and embedded by `pipeline.rs` through `include_bytes!`, so no compiled artifact is committed to the repository and a source edit can never disagree with the binary shipped beside it. A compilation failure aborts the build, naming the offending shader and reproducing the compiler diagnostic verbatim.
|
||||
//!
|
||||
//! `#include` directives are deliberately not resolved. No shader uses one yet, and enabling them requires registering each included path for change tracking as well; a shared header added without that tracking would not retrigger compilation when edited, which is the exact staleness this script exists to prevent.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// The GLSL sources compiled into the crate, paired with the pipeline stage each one targets.
|
||||
const SHADERS: [(&str, shaderc::ShaderKind); 2] = [
|
||||
("cube.vert", shaderc::ShaderKind::Vertex),
|
||||
("cube.frag", shaderc::ShaderKind::Fragment),
|
||||
];
|
||||
|
||||
/// Compiles every entry of [`SHADERS`] into `OUT_DIR`.
|
||||
///
|
||||
/// # Panics
|
||||
///
|
||||
/// Panics if a Cargo-provided environment variable is absent, if the shader compiler or its options cannot be constructed, or if any individual shader fails to read, compile, or write.
|
||||
fn main() {
|
||||
let manifest_dir = required_var("CARGO_MANIFEST_DIR");
|
||||
let out_dir = required_var("OUT_DIR");
|
||||
|
||||
// Shaders are shared repository assets rather than crate-local sources, so they resolve relative to the crate root instead of living under `src/`.
|
||||
let shader_dir = Path::new(&manifest_dir).join("../../assets/shaders");
|
||||
|
||||
let compiler = shaderc::Compiler::new()
|
||||
.unwrap_or_else(|error| panic!("failed to initialise the shader compiler: {error}"));
|
||||
|
||||
let mut options = shaderc::CompileOptions::new()
|
||||
.unwrap_or_else(|error| panic!("failed to create the shader compiler options: {error}"));
|
||||
|
||||
// The target environment must match the API the modules are consumed by; the renderer uses Vulkan 1.3 dynamic rendering.
|
||||
options.set_target_env(
|
||||
shaderc::TargetEnv::Vulkan,
|
||||
shaderc::EnvVersion::Vulkan1_3 as u32,
|
||||
);
|
||||
options.set_optimization_level(shaderc::OptimizationLevel::Performance);
|
||||
|
||||
for (name, kind) in SHADERS {
|
||||
compile_shader(
|
||||
&compiler,
|
||||
&options,
|
||||
&shader_dir,
|
||||
Path::new(&out_dir),
|
||||
name,
|
||||
kind,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Compiles the shader `name` from `shader_dir` into `<out_dir>/<name>.spv`.
|
||||
///
|
||||
/// # Panics
|
||||
///
|
||||
/// Panics if the source cannot be read, if the shader fails to compile, or if the resulting module cannot be written.
|
||||
fn compile_shader(
|
||||
compiler: &shaderc::Compiler,
|
||||
options: &shaderc::CompileOptions,
|
||||
shader_dir: &Path,
|
||||
out_dir: &Path,
|
||||
name: &str,
|
||||
kind: shaderc::ShaderKind,
|
||||
) {
|
||||
let source_path = shader_dir.join(name);
|
||||
|
||||
// Emitting any directive replaces Cargo's default of rerunning whenever the package changes, so every source consumed here must be registered explicitly or edits to it stop triggering a rebuild.
|
||||
println!("cargo::rerun-if-changed={}", source_path.display());
|
||||
|
||||
let source = std::fs::read_to_string(&source_path).unwrap_or_else(|error| {
|
||||
panic!(
|
||||
"failed to read the shader source {}: {error}",
|
||||
source_path.display()
|
||||
)
|
||||
});
|
||||
|
||||
let artifact = compiler
|
||||
.compile_into_spirv(&source, kind, name, "main", Some(options))
|
||||
.unwrap_or_else(|error| panic!("failed to compile the shader {name}:\n{error}"));
|
||||
|
||||
let output_path: PathBuf = out_dir.join(format!("{name}.spv"));
|
||||
std::fs::write(&output_path, artifact.as_binary_u8()).unwrap_or_else(|error| {
|
||||
panic!(
|
||||
"failed to write the compiled shader {}: {error}",
|
||||
output_path.display()
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
/// Returns the value of a Cargo-provided environment variable.
|
||||
///
|
||||
/// # Panics
|
||||
///
|
||||
/// Panics if `name` is not set, which indicates the script was not invoked by Cargo.
|
||||
fn required_var(name: &str) -> String {
|
||||
std::env::var(name)
|
||||
.unwrap_or_else(|error| panic!("the environment variable {name} is not set: {error}"))
|
||||
}
|
||||
242
crates/renderer/src/device.rs
Normal file
242
crates/renderer/src/device.rs
Normal file
|
|
@ -0,0 +1,242 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
//! Logic for selecting physical devices and creating logical devices.
|
||||
|
||||
use crate::error::RendererError;
|
||||
use crate::stats::{GpuInfo, decode_driver_version};
|
||||
use ash::{Device, Instance, khr, vk};
|
||||
|
||||
/// Picks a physical device (GPU) that supports the required features and extensions.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`RendererError::VulkanError`] if physical devices cannot be enumerated, or [`RendererError::NoSuitableGpu`] if none meets the requirements.
|
||||
pub fn pick_physical_device(
|
||||
instance: &Instance,
|
||||
surface_loader: &khr::surface::Instance,
|
||||
surface: vk::SurfaceKHR,
|
||||
) -> Result<vk::PhysicalDevice, RendererError> {
|
||||
let devices = unsafe { instance.enumerate_physical_devices()? };
|
||||
|
||||
for device in devices {
|
||||
if is_device_suitable(instance, device, surface_loader, surface) {
|
||||
let props = unsafe { instance.get_physical_device_properties(device) };
|
||||
let name =
|
||||
unsafe { std::ffi::CStr::from_ptr(props.device_name.as_ptr()).to_string_lossy() };
|
||||
tracing::info!("Selected GPU: \"{name}\"");
|
||||
return Ok(device);
|
||||
}
|
||||
}
|
||||
|
||||
Err(RendererError::NoSuitableGpu)
|
||||
}
|
||||
|
||||
/// Creates a logical device and retrieves the graphics queue.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`RendererError::VulkanError`] if the device cannot be created.
|
||||
pub fn create_logical_device(
|
||||
instance: &Instance,
|
||||
physical_device: vk::PhysicalDevice,
|
||||
queue_family_index: u32,
|
||||
memory_budget: bool,
|
||||
) -> Result<(Device, vk::Queue), RendererError> {
|
||||
let priorities = [1.0];
|
||||
let queue_info = vk::DeviceQueueCreateInfo::default()
|
||||
.queue_family_index(queue_family_index)
|
||||
.queue_priorities(&priorities);
|
||||
|
||||
// `VK_EXT_memory_budget` is optional and is requested only where the device advertises it; naming an unsupported extension fails device creation outright.
|
||||
let mut device_extensions = vec![khr::swapchain::NAME.as_ptr()];
|
||||
if memory_budget {
|
||||
device_extensions.push(ash::ext::memory_budget::NAME.as_ptr());
|
||||
}
|
||||
|
||||
// Enable Vulkan 1.3 features
|
||||
let mut synchronization2_features =
|
||||
vk::PhysicalDeviceSynchronization2Features::default().synchronization2(true);
|
||||
let mut dynamic_rendering_features =
|
||||
vk::PhysicalDeviceDynamicRenderingFeatures::default().dynamic_rendering(true);
|
||||
|
||||
// `fillModeNonSolid` unlocks the `POINT` and `LINE` polygon modes used by the debug render modes. `largePoints` permits a shader-written point size above 1.0, without which debug points rasterise as single pixels.
|
||||
let enabled_features = vk::PhysicalDeviceFeatures::default()
|
||||
.fill_mode_non_solid(true)
|
||||
.large_points(true);
|
||||
|
||||
let create_info = vk::DeviceCreateInfo::default()
|
||||
.queue_create_infos(std::slice::from_ref(&queue_info))
|
||||
.enabled_extension_names(&device_extensions)
|
||||
.enabled_features(&enabled_features)
|
||||
.push_next(&mut synchronization2_features)
|
||||
.push_next(&mut dynamic_rendering_features);
|
||||
|
||||
let device = unsafe { instance.create_device(physical_device, &create_info, None)? };
|
||||
let graphics_queue = unsafe { device.get_device_queue(queue_family_index, 0) };
|
||||
|
||||
Ok((device, graphics_queue))
|
||||
}
|
||||
|
||||
/// Finds a queue family that supports both graphics commands and presentation.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`RendererError::VulkanError`] if surface-support queries fail, or [`RendererError::NoSuitableGpu`] if no family supports both graphics and presentation.
|
||||
pub fn find_graphics_queue_family(
|
||||
instance: &Instance,
|
||||
physical_device: vk::PhysicalDevice,
|
||||
surface_loader: &khr::surface::Instance,
|
||||
surface: vk::SurfaceKHR,
|
||||
) -> Result<u32, RendererError> {
|
||||
let props = unsafe { instance.get_physical_device_queue_family_properties(physical_device) };
|
||||
|
||||
for (index, prop) in props.iter().enumerate() {
|
||||
#[expect(
|
||||
clippy::expect_used,
|
||||
reason = "a physical device's queue-family count never approaches u32::MAX"
|
||||
)]
|
||||
let index = u32::try_from(index).expect("Queue family index exceeds u32 range");
|
||||
let graphics = prop.queue_flags.contains(vk::QueueFlags::GRAPHICS);
|
||||
let present = unsafe {
|
||||
surface_loader.get_physical_device_surface_support(physical_device, index, surface)?
|
||||
};
|
||||
|
||||
if graphics && present {
|
||||
return Ok(index);
|
||||
}
|
||||
}
|
||||
|
||||
Err(RendererError::NoSuitableGpu)
|
||||
}
|
||||
|
||||
/// Reports whether `physical_device` advertises the optional `VK_EXT_memory_budget` extension.
|
||||
///
|
||||
/// The extension is what makes driver-side VRAM usage and budget readable; without it those figures are simply unavailable, which is a reportable state rather than an error.
|
||||
pub fn supports_memory_budget(instance: &Instance, physical_device: vk::PhysicalDevice) -> bool {
|
||||
has_extension(instance, physical_device, ash::ext::memory_budget::NAME)
|
||||
}
|
||||
|
||||
/// Queries the immutable properties of `physical_device` into a reportable snapshot.
|
||||
///
|
||||
/// `memory_budget` records whether the optional budget extension was enabled on the logical device, since the caller owns that decision and this query cannot observe it.
|
||||
pub fn query_gpu_info(
|
||||
instance: &Instance,
|
||||
physical_device: vk::PhysicalDevice,
|
||||
memory_budget: bool,
|
||||
) -> GpuInfo {
|
||||
let props = unsafe { instance.get_physical_device_properties(physical_device) };
|
||||
let memory_props = unsafe { instance.get_physical_device_memory_properties(physical_device) };
|
||||
|
||||
// The name is a fixed-size, NUL-terminated array of `c_char`; `to_string_lossy` substitutes replacement characters rather than failing on a malformed driver string.
|
||||
let device_name = unsafe { std::ffi::CStr::from_ptr(props.device_name.as_ptr()) }
|
||||
.to_string_lossy()
|
||||
.into_owned();
|
||||
|
||||
GpuInfo {
|
||||
device_name,
|
||||
device_type: device_type_name(props.device_type),
|
||||
vendor_id: props.vendor_id,
|
||||
device_id: props.device_id,
|
||||
driver_version: decode_driver_version(props.vendor_id, props.driver_version),
|
||||
api_version: format!(
|
||||
"{}.{}.{}",
|
||||
vk::api_version_major(props.api_version),
|
||||
vk::api_version_minor(props.api_version),
|
||||
vk::api_version_patch(props.api_version)
|
||||
),
|
||||
vram_total_bytes: device_local_heap_bytes(&memory_props),
|
||||
memory_budget_supported: memory_budget,
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads the driver's current usage and budget across the device-local heaps.
|
||||
///
|
||||
/// Returns `(usage, budget)` in bytes. Both are [`None`] unless `VK_EXT_memory_budget` is enabled, since the values are carried in a structure the extension defines. The figures cover every process on the device, not only this one.
|
||||
pub fn query_memory_budget(
|
||||
instance: &Instance,
|
||||
physical_device: vk::PhysicalDevice,
|
||||
memory_budget: bool,
|
||||
) -> (Option<u64>, Option<u64>) {
|
||||
if !memory_budget {
|
||||
return (None, None);
|
||||
}
|
||||
|
||||
let mut budget_props = vk::PhysicalDeviceMemoryBudgetPropertiesEXT::default();
|
||||
let mut props = vk::PhysicalDeviceMemoryProperties2::default().push_next(&mut budget_props);
|
||||
unsafe {
|
||||
instance.get_physical_device_memory_properties2(physical_device, &mut props);
|
||||
}
|
||||
|
||||
// Only the device-local heaps are of interest; host-visible system-memory heaps are not the resource under pressure. The three arrays are parallel and all sized `VK_MAX_MEMORY_HEAPS`, so zipping them cannot desynchronise.
|
||||
let heaps = &props.memory_properties;
|
||||
let count = heaps.memory_heap_count as usize;
|
||||
let (usage, budget) = heaps.memory_heaps[..count]
|
||||
.iter()
|
||||
.zip(&budget_props.heap_usage[..count])
|
||||
.zip(&budget_props.heap_budget[..count])
|
||||
.filter(|((heap, _), _)| heap.flags.contains(vk::MemoryHeapFlags::DEVICE_LOCAL))
|
||||
.fold((0u64, 0u64), |(usage, budget), ((_, used), allowed)| {
|
||||
(usage.saturating_add(*used), budget.saturating_add(*allowed))
|
||||
});
|
||||
|
||||
(Some(usage), Some(budget))
|
||||
}
|
||||
|
||||
/// Sums the capacity of every heap flagged `DEVICE_LOCAL`, in bytes.
|
||||
fn device_local_heap_bytes(props: &vk::PhysicalDeviceMemoryProperties) -> u64 {
|
||||
props.memory_heaps[..props.memory_heap_count as usize]
|
||||
.iter()
|
||||
.filter(|heap| heap.flags.contains(vk::MemoryHeapFlags::DEVICE_LOCAL))
|
||||
.map(|heap| heap.size)
|
||||
.sum()
|
||||
}
|
||||
|
||||
/// Returns a human-readable name for a physical-device class.
|
||||
const fn device_type_name(device_type: vk::PhysicalDeviceType) -> &'static str {
|
||||
match device_type {
|
||||
vk::PhysicalDeviceType::DISCRETE_GPU => "discrete",
|
||||
vk::PhysicalDeviceType::INTEGRATED_GPU => "integrated",
|
||||
vk::PhysicalDeviceType::VIRTUAL_GPU => "virtual",
|
||||
vk::PhysicalDeviceType::CPU => "cpu",
|
||||
_ => "other",
|
||||
}
|
||||
}
|
||||
|
||||
/// Reports whether `physical_device` advertises the named device extension.
|
||||
fn has_extension(
|
||||
instance: &Instance,
|
||||
physical_device: vk::PhysicalDevice,
|
||||
name: &std::ffi::CStr,
|
||||
) -> bool {
|
||||
let extensions = unsafe {
|
||||
instance
|
||||
.enumerate_device_extension_properties(physical_device)
|
||||
.unwrap_or_default()
|
||||
};
|
||||
extensions
|
||||
.iter()
|
||||
.any(|ext| unsafe { std::ffi::CStr::from_ptr(ext.extension_name.as_ptr()) } == name)
|
||||
}
|
||||
|
||||
/// Reports whether a physical device can present to `surface` and supports the extensions the renderer requires.
|
||||
fn is_device_suitable(
|
||||
instance: &Instance,
|
||||
device: vk::PhysicalDevice,
|
||||
surface_loader: &khr::surface::Instance,
|
||||
surface: vk::SurfaceKHR,
|
||||
) -> bool {
|
||||
let has_swapchain = has_extension(instance, device, khr::swapchain::NAME);
|
||||
|
||||
let formats = unsafe {
|
||||
surface_loader
|
||||
.get_physical_device_surface_formats(device, surface)
|
||||
.unwrap_or_default()
|
||||
};
|
||||
let present_modes = unsafe {
|
||||
surface_loader
|
||||
.get_physical_device_surface_present_modes(device, surface)
|
||||
.unwrap_or_default()
|
||||
};
|
||||
|
||||
has_swapchain && !formats.is_empty() && !present_modes.is_empty()
|
||||
}
|
||||
34
crates/renderer/src/error.rs
Normal file
34
crates/renderer/src/error.rs
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
//! Error types for the renderer crate.
|
||||
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
/// Enumerates all possible errors that can occur during rendering operations.
|
||||
pub enum RendererError {
|
||||
/// The Vulkan library could not be loaded from the system.
|
||||
#[error("Failed to load Vulkan library")]
|
||||
LoadFailed(#[from] ash::LoadingError),
|
||||
/// A raw Vulkan result indicated a failure.
|
||||
#[error("Vulkan error")]
|
||||
VulkanError(#[from] ash::vk::Result),
|
||||
/// No GPU was found that meets the engine's requirements.
|
||||
#[error("No suitable GPU found")]
|
||||
NoSuitableGpu,
|
||||
/// An error occurred during GPU memory allocation.
|
||||
#[error("GPU allocation error")]
|
||||
AllocationError(#[from] gpu_allocator::AllocationError),
|
||||
/// An I/O error occurred (e.g. reading a shader).
|
||||
#[error("I/O error")]
|
||||
IoError(#[from] std::io::Error),
|
||||
/// An invalid string was encountered.
|
||||
#[error("Invalid string")]
|
||||
InvalidString,
|
||||
/// The renderer's synchronization primitives were unavailable.
|
||||
#[error("Synchronization primitives missing")]
|
||||
SyncPrimitivesMissing,
|
||||
/// The renderer's GPU memory allocator was unavailable.
|
||||
#[error("GPU allocator missing")]
|
||||
AllocatorMissing,
|
||||
}
|
||||
66
crates/renderer/src/frustum.rs
Normal file
66
crates/renderer/src/frustum.rs
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
//! CPU-side view-frustum culling for chunk meshes.
|
||||
|
||||
use glam::{Mat4, Vec3, Vec4};
|
||||
|
||||
/// Six view-frustum planes in world space.
|
||||
///
|
||||
/// Each plane is stored as a [`Vec4`] `(a, b, c, d)` where `(a, b, c)` is the inward-facing normal and the plane equation is `a·x + b·y + c·z + d = 0`. The planes are normalised, so evaluating the equation at a point yields the signed distance from that point to the plane; a non-negative result lies on the interior side.
|
||||
pub(crate) struct Frustum {
|
||||
/// The six planes in the order left, right, bottom, top, near, far.
|
||||
planes: [Vec4; 6],
|
||||
}
|
||||
|
||||
impl Frustum {
|
||||
/// Builds the frustum from a combined view-projection matrix.
|
||||
///
|
||||
/// The matrix is expected to map world space into Vulkan clip space, whose depth range is `[0, 1]`. Under that convention the near plane is the third matrix row alone (`r2`), not `r3 + r2` as in the OpenGL `[-1, 1]` range; the OpenGL form would cull geometry directly ahead of the camera. Each plane is normalised by the length of its `(a, b, c)` normal so subsequent evaluations return true signed distances.
|
||||
pub(crate) fn from_view_proj(mvp: Mat4) -> Self {
|
||||
// glam stores matrices column-major; the Gribb–Hartmann derivation operates on the rows of the combined matrix, so rows are read here rather than columns.
|
||||
let r0 = mvp.row(0);
|
||||
let r1 = mvp.row(1);
|
||||
let r2 = mvp.row(2);
|
||||
let r3 = mvp.row(3);
|
||||
|
||||
let mut planes = [
|
||||
r3 + r0, // left
|
||||
r3 - r0, // right
|
||||
r3 + r1, // bottom
|
||||
r3 - r1, // top
|
||||
r2, // near (Vulkan depth range [0, 1], hence r2 alone)
|
||||
r3 - r2, // far
|
||||
];
|
||||
|
||||
for plane in &mut planes {
|
||||
let normal_length = plane.truncate().length();
|
||||
*plane /= normal_length;
|
||||
}
|
||||
|
||||
Self { planes }
|
||||
}
|
||||
|
||||
/// Returns whether the axis-aligned box spanning `[min, max]` is at least partially inside the frustum.
|
||||
pub(crate) fn intersects_aabb(&self, min: Vec3, max: Vec3) -> bool {
|
||||
for plane in &self.planes {
|
||||
let normal = plane.truncate();
|
||||
|
||||
// The "positive vertex" is the box corner farthest along the plane normal: per axis the max component is taken when the normal's component is non-negative, otherwise the min. If even that corner lies behind the plane, the whole box does.
|
||||
let positive_vertex = Vec3::new(
|
||||
if normal.x >= 0.0 { max.x } else { min.x },
|
||||
if normal.y >= 0.0 { max.y } else { min.y },
|
||||
if normal.z >= 0.0 { max.z } else { min.z },
|
||||
);
|
||||
|
||||
if plane.dot(positive_vertex.extend(1.0)) < 0.0 {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tests/frustum.rs"]
|
||||
mod tests;
|
||||
116
crates/renderer/src/instance.rs
Normal file
116
crates/renderer/src/instance.rs
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
use crate::error::RendererError;
|
||||
use ash::{Entry, Instance, ext, vk};
|
||||
use std::ffi::c_char;
|
||||
// `CStr` and the tracing macros are used only by the debug-build validation callback.
|
||||
#[cfg(debug_assertions)]
|
||||
use std::ffi::CStr;
|
||||
#[cfg(debug_assertions)]
|
||||
use tracing::{debug, error, info, warn};
|
||||
|
||||
/// Creates a Vulkan instance and optionally a debug messenger.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`RendererError::VulkanError`] if instance creation fails, or if the debug messenger cannot be created in debug builds.
|
||||
pub fn create_instance(
|
||||
entry: &Entry,
|
||||
required_extensions: &[*const c_char],
|
||||
) -> Result<
|
||||
(
|
||||
Instance,
|
||||
Option<ext::debug_utils::Instance>,
|
||||
vk::DebugUtilsMessengerEXT,
|
||||
),
|
||||
RendererError,
|
||||
> {
|
||||
// The validation extension and layer are pushed only in debug builds, so in release builds these vectors are never mutated after initialization.
|
||||
#[cfg_attr(
|
||||
not(debug_assertions),
|
||||
expect(
|
||||
unused_mut,
|
||||
reason = "the debug-only block below mutates these vectors"
|
||||
)
|
||||
)]
|
||||
let mut extensions = required_extensions.to_vec();
|
||||
#[cfg_attr(
|
||||
not(debug_assertions),
|
||||
expect(
|
||||
unused_mut,
|
||||
reason = "the debug-only block below mutates these vectors"
|
||||
)
|
||||
)]
|
||||
let mut layers = Vec::new();
|
||||
|
||||
#[cfg(debug_assertions)]
|
||||
{
|
||||
extensions.push(ext::debug_utils::NAME.as_ptr());
|
||||
layers.push(c"VK_LAYER_KHRONOS_validation".as_ptr());
|
||||
}
|
||||
|
||||
let app_info = vk::ApplicationInfo::default().api_version(vk::API_VERSION_1_3);
|
||||
|
||||
let create_info = vk::InstanceCreateInfo::default()
|
||||
.application_info(&app_info)
|
||||
.enabled_extension_names(&extensions)
|
||||
.enabled_layer_names(&layers);
|
||||
|
||||
let instance = unsafe { entry.create_instance(&create_info, None)? };
|
||||
|
||||
#[cfg(debug_assertions)]
|
||||
let (debug_utils, debug_messenger) = {
|
||||
let debug_info = vk::DebugUtilsMessengerCreateInfoEXT::default()
|
||||
.message_severity(
|
||||
vk::DebugUtilsMessageSeverityFlagsEXT::WARNING
|
||||
| vk::DebugUtilsMessageSeverityFlagsEXT::ERROR,
|
||||
)
|
||||
.message_type(
|
||||
vk::DebugUtilsMessageTypeFlagsEXT::GENERAL
|
||||
| vk::DebugUtilsMessageTypeFlagsEXT::VALIDATION
|
||||
| vk::DebugUtilsMessageTypeFlagsEXT::PERFORMANCE,
|
||||
)
|
||||
.pfn_user_callback(Some(vulkan_debug_callback));
|
||||
|
||||
let utils = ext::debug_utils::Instance::new(entry, &instance);
|
||||
let messenger = unsafe { utils.create_debug_utils_messenger(&debug_info, None)? };
|
||||
|
||||
(Some(utils), messenger)
|
||||
};
|
||||
|
||||
#[cfg(not(debug_assertions))]
|
||||
let (debug_utils, debug_messenger) = (None, vk::DebugUtilsMessengerEXT::null());
|
||||
|
||||
Ok((instance, debug_utils, debug_messenger))
|
||||
}
|
||||
|
||||
/// The callback function invoked by Vulkan's validation layers.
|
||||
///
|
||||
/// # Safety
|
||||
///
|
||||
/// Invoked by the Vulkan loader, which must pass a valid `p_callback_data` pointer whose `p_message` is either null or a valid NUL-terminated C string. Not to be called directly.
|
||||
#[cfg(debug_assertions)]
|
||||
unsafe extern "system" fn vulkan_debug_callback(
|
||||
message_severity: vk::DebugUtilsMessageSeverityFlagsEXT,
|
||||
_message_type: vk::DebugUtilsMessageTypeFlagsEXT,
|
||||
p_callback_data: *const vk::DebugUtilsMessengerCallbackDataEXT<'_>,
|
||||
_user_data: *mut std::ffi::c_void,
|
||||
) -> vk::Bool32 {
|
||||
let callback_data = unsafe { *p_callback_data };
|
||||
|
||||
let message = if callback_data.p_message.is_null() {
|
||||
"".into()
|
||||
} else {
|
||||
unsafe { CStr::from_ptr(callback_data.p_message).to_string_lossy() }
|
||||
};
|
||||
|
||||
match message_severity {
|
||||
vk::DebugUtilsMessageSeverityFlagsEXT::VERBOSE => debug!("{message}"),
|
||||
vk::DebugUtilsMessageSeverityFlagsEXT::INFO => info!("{message}"),
|
||||
vk::DebugUtilsMessageSeverityFlagsEXT::WARNING => warn!("{message}"),
|
||||
vk::DebugUtilsMessageSeverityFlagsEXT::ERROR => error!("{message}"),
|
||||
_ => info!("{message}"),
|
||||
}
|
||||
|
||||
vk::FALSE
|
||||
}
|
||||
324
crates/renderer/src/lib.rs
Normal file
324
crates/renderer/src/lib.rs
Normal file
|
|
@ -0,0 +1,324 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
#![allow(unsafe_code)]
|
||||
|
||||
//! Voxel rendering engine using Vulkan 1.3 and dynamic rendering.
|
||||
//!
|
||||
//! This crate provides the core `Renderer` structure and associated types
|
||||
//! for handling GPU resources and drawing operations.
|
||||
|
||||
mod device;
|
||||
pub mod error;
|
||||
mod frustum;
|
||||
mod instance;
|
||||
pub mod meshing;
|
||||
mod pipeline;
|
||||
mod renderer;
|
||||
pub mod stats;
|
||||
mod surface;
|
||||
mod swapchain;
|
||||
mod sync;
|
||||
pub mod vertex;
|
||||
|
||||
/// The maximum number of frames that can be processed by the GPU and CPU simultaneously.
|
||||
pub const MAX_FRAMES_IN_FLIGHT: usize = 3;
|
||||
|
||||
use ash::{Entry, vk};
|
||||
use gpu_allocator::vulkan::{Allocation, Allocator};
|
||||
use raw_window_handle::{RawDisplayHandle, RawWindowHandle};
|
||||
use std::ffi::c_char;
|
||||
|
||||
pub use error::RendererError;
|
||||
pub use renderer::{FrameParams, MeshKey, RasterPass, RenderMode, Renderer};
|
||||
pub use stats::{GpuInfo, MemoryUsage, ProjectionInfo, RenderStats, SwapchainInfo};
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
impl Renderer {
|
||||
/// Initializes the Vulkan renderer.
|
||||
///
|
||||
/// This function loads the Vulkan library, creates an instance, selects a GPU,
|
||||
/// and initializes a logical device with a graphics queue.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`RendererError`] if any initialization step fails: loading Vulkan, creating the instance, surface, device, swapchain, pipeline, allocator, or initial geometry.
|
||||
///
|
||||
/// # Panics
|
||||
///
|
||||
/// Panics if `MAX_FRAMES_IN_FLIGHT` exceeds `u32`'s range.
|
||||
// TODO: partial-construction leak. Each `?` below early-returns and leaks every Vulkan resource created so far; only a fully successful `new` reaches `Drop for Renderer`. Once the renderer grows more state, wrap each resource in an RAII guard so failure paths tear them down too.
|
||||
pub fn new(
|
||||
display_handle: RawDisplayHandle,
|
||||
window_handle: RawWindowHandle,
|
||||
width: u32,
|
||||
height: u32,
|
||||
required_extensions: &[*const c_char],
|
||||
) -> Result<Self, RendererError> {
|
||||
let entry = unsafe { Entry::load() }?;
|
||||
|
||||
// 1. Instance and Debug Messenger
|
||||
let (instance, debug_utils, debug_messenger) =
|
||||
instance::create_instance(&entry, required_extensions)?;
|
||||
|
||||
// 2. Surface
|
||||
let (surface_loader, surface) =
|
||||
surface::create_surface(&entry, &instance, display_handle, window_handle)?;
|
||||
|
||||
// 3. Physical Device (GPU)
|
||||
let physical_device = device::pick_physical_device(&instance, &surface_loader, surface)?;
|
||||
|
||||
// 4. Graphics Queue Index
|
||||
let graphics_queue_index = device::find_graphics_queue_family(
|
||||
&instance,
|
||||
physical_device,
|
||||
&surface_loader,
|
||||
surface,
|
||||
)?;
|
||||
|
||||
// 5. Logical Device and Queue
|
||||
// Driver-side memory reporting is optional; the extension is detected here so it can be both enabled on the device and recorded in the reported device information.
|
||||
let memory_budget = device::supports_memory_budget(&instance, physical_device);
|
||||
let (device, graphics_queue) = device::create_logical_device(
|
||||
&instance,
|
||||
physical_device,
|
||||
graphics_queue_index,
|
||||
memory_budget,
|
||||
)?;
|
||||
let gpu_info = device::query_gpu_info(&instance, physical_device, memory_budget);
|
||||
|
||||
// 6. Swapchain
|
||||
let (swapchain_loader, swapchain, swapchain_images, swapchain_format, swapchain_extent) =
|
||||
swapchain::create_swapchain(
|
||||
&instance,
|
||||
physical_device,
|
||||
&device,
|
||||
&surface_loader,
|
||||
surface,
|
||||
width,
|
||||
height,
|
||||
)?;
|
||||
|
||||
// 7. Image Views
|
||||
let image_views =
|
||||
swapchain::create_image_views(&device, &swapchain_images, swapchain_format)?;
|
||||
|
||||
// 8. Command Pool
|
||||
let pool_create_info = vk::CommandPoolCreateInfo::default()
|
||||
.queue_family_index(graphics_queue_index)
|
||||
.flags(vk::CommandPoolCreateFlags::RESET_COMMAND_BUFFER);
|
||||
|
||||
let command_pool = unsafe { device.create_command_pool(&pool_create_info, None)? };
|
||||
|
||||
// 9. Command Buffers
|
||||
#[expect(
|
||||
clippy::expect_used,
|
||||
reason = "MAX_FRAMES_IN_FLIGHT is a small compile-time constant"
|
||||
)]
|
||||
let alloc_info = vk::CommandBufferAllocateInfo::default()
|
||||
.command_pool(command_pool)
|
||||
.level(vk::CommandBufferLevel::PRIMARY)
|
||||
.command_buffer_count(
|
||||
u32::try_from(MAX_FRAMES_IN_FLIGHT).expect("MAX_FRAMES_IN_FLIGHT exceeds u32"),
|
||||
);
|
||||
|
||||
let command_buffers = unsafe { device.allocate_command_buffers(&alloc_info)? };
|
||||
|
||||
// 10. Synchronization Primitives
|
||||
let sync =
|
||||
sync::create_sync_primitives(&device, MAX_FRAMES_IN_FLIGHT, swapchain_images.len())?;
|
||||
|
||||
// 11. GPU Memory Allocator
|
||||
let mut allocator = create_allocator(&instance, &device, physical_device)?;
|
||||
|
||||
// 12. Graphics Pipeline Configuration
|
||||
let pipeline_layout = pipeline::create_pipeline_layout(&device)?;
|
||||
|
||||
// One pipeline per raster pass, built up front so selecting a mode is a bind-time choice rather than a pipeline compilation stall. All variants share `pipeline_layout`; only their rasterisation and depth-compare state differs.
|
||||
let mut pipelines = [vk::Pipeline::null(); RasterPass::COUNT];
|
||||
for pass in RasterPass::ALL {
|
||||
pipelines[pass.index()] = pipeline::create_graphics_pipeline(
|
||||
&device,
|
||||
pipeline_layout,
|
||||
swapchain_format,
|
||||
pass.polygon_mode(),
|
||||
pass.depth_compare_op(),
|
||||
)?;
|
||||
}
|
||||
|
||||
let (depth_image, depth_allocation, depth_image_view) =
|
||||
create_depth_resources(&device, &mut allocator, swapchain_extent)?;
|
||||
|
||||
Ok(Self {
|
||||
_entry: entry,
|
||||
instance,
|
||||
debug_utils,
|
||||
debug_messenger,
|
||||
physical_device,
|
||||
device,
|
||||
graphics_queue,
|
||||
graphics_queue_index,
|
||||
surface_loader,
|
||||
surface,
|
||||
swapchain_loader,
|
||||
swapchain,
|
||||
swapchain_images,
|
||||
swapchain_format,
|
||||
swapchain_extent,
|
||||
swapchain_image_views: image_views,
|
||||
command_pool,
|
||||
command_buffers,
|
||||
allocator: Some(allocator),
|
||||
chunk_meshes: HashMap::new(),
|
||||
depth_image,
|
||||
depth_allocation: Some(depth_allocation),
|
||||
depth_image_view,
|
||||
pipeline_layout,
|
||||
pipelines,
|
||||
render_mode: RenderMode::default(),
|
||||
gpu_info,
|
||||
memory_budget,
|
||||
sync: Some(sync),
|
||||
current_frame: 0,
|
||||
present_mode: swapchain::present_mode_name(swapchain::PRESENT_MODE),
|
||||
frames_presented: 0,
|
||||
frames_skipped: 0,
|
||||
last_frame_stats: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a GPU memory allocator.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`RendererError::AllocationError`] if the allocator cannot be initialized.
|
||||
fn create_allocator(
|
||||
instance: &ash::Instance,
|
||||
device: &ash::Device,
|
||||
physical_device: vk::PhysicalDevice,
|
||||
) -> Result<gpu_allocator::vulkan::Allocator, RendererError> {
|
||||
let allocator_create_info = gpu_allocator::vulkan::AllocatorCreateDesc {
|
||||
instance: instance.clone(),
|
||||
device: device.clone(),
|
||||
physical_device,
|
||||
debug_settings: gpu_allocator::AllocatorDebugSettings::default(),
|
||||
buffer_device_address: false,
|
||||
allocation_sizes: gpu_allocator::AllocationSizes::default(),
|
||||
};
|
||||
|
||||
let allocator = gpu_allocator::vulkan::Allocator::new(&allocator_create_info)
|
||||
.map_err(RendererError::AllocationError)?;
|
||||
Ok(allocator)
|
||||
}
|
||||
|
||||
/// Creates the depth buffer resources (image, memory, and view).
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`RendererError::AllocationError`] if GPU memory cannot be allocated, or [`RendererError::VulkanError`] if the depth image or its view cannot be created.
|
||||
fn create_depth_resources(
|
||||
device: &ash::Device,
|
||||
allocator: &mut Allocator,
|
||||
extent: vk::Extent2D,
|
||||
) -> Result<(vk::Image, Allocation, vk::ImageView), RendererError> {
|
||||
let depth_format = vk::Format::D32_SFLOAT;
|
||||
|
||||
let image_create_info = vk::ImageCreateInfo::default()
|
||||
.image_type(vk::ImageType::TYPE_2D)
|
||||
.format(depth_format)
|
||||
.extent(vk::Extent3D {
|
||||
width: extent.width,
|
||||
height: extent.height,
|
||||
depth: 1,
|
||||
})
|
||||
.mip_levels(1)
|
||||
.array_layers(1)
|
||||
.samples(vk::SampleCountFlags::TYPE_1)
|
||||
.tiling(vk::ImageTiling::OPTIMAL)
|
||||
.usage(vk::ImageUsageFlags::DEPTH_STENCIL_ATTACHMENT)
|
||||
.sharing_mode(vk::SharingMode::EXCLUSIVE)
|
||||
.initial_layout(vk::ImageLayout::UNDEFINED);
|
||||
|
||||
let depth_image = unsafe { device.create_image(&image_create_info, None)? };
|
||||
|
||||
let requirements = unsafe { device.get_image_memory_requirements(depth_image) };
|
||||
|
||||
let depth_allocation = allocator.allocate(&gpu_allocator::vulkan::AllocationCreateDesc {
|
||||
name: "Depth Image",
|
||||
requirements,
|
||||
location: gpu_allocator::MemoryLocation::GpuOnly,
|
||||
linear: false,
|
||||
allocation_scheme: gpu_allocator::vulkan::AllocationScheme::GpuAllocatorManaged,
|
||||
})?;
|
||||
|
||||
unsafe {
|
||||
device.bind_image_memory(
|
||||
depth_image,
|
||||
depth_allocation.memory(),
|
||||
depth_allocation.offset(),
|
||||
)?;
|
||||
}
|
||||
|
||||
let view_create_info = vk::ImageViewCreateInfo::default()
|
||||
.image(depth_image)
|
||||
.view_type(vk::ImageViewType::TYPE_2D)
|
||||
.format(depth_format)
|
||||
.subresource_range(vk::ImageSubresourceRange {
|
||||
aspect_mask: vk::ImageAspectFlags::DEPTH,
|
||||
base_mip_level: 0,
|
||||
level_count: 1,
|
||||
base_array_layer: 0,
|
||||
layer_count: 1,
|
||||
});
|
||||
|
||||
let depth_image_view = unsafe { device.create_image_view(&view_create_info, None)? };
|
||||
|
||||
Ok((depth_image, depth_allocation, depth_image_view))
|
||||
}
|
||||
|
||||
/// Helper function to create and populate a GPU buffer.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`RendererError::AllocationError`] if GPU memory cannot be allocated, or [`RendererError::VulkanError`] if the buffer cannot be created or bound.
|
||||
fn create_gpu_buffer(
|
||||
device: &ash::Device,
|
||||
allocator: &mut Allocator,
|
||||
data: &[u8],
|
||||
usage: vk::BufferUsageFlags,
|
||||
name: &str,
|
||||
) -> Result<(vk::Buffer, Allocation), RendererError> {
|
||||
let size = data.len() as u64;
|
||||
|
||||
let buffer_info = vk::BufferCreateInfo::default()
|
||||
.size(size)
|
||||
.usage(usage)
|
||||
.sharing_mode(vk::SharingMode::EXCLUSIVE);
|
||||
|
||||
let buffer = unsafe { device.create_buffer(&buffer_info, None)? };
|
||||
|
||||
let requirements = unsafe { device.get_buffer_memory_requirements(buffer) };
|
||||
let allocation = allocator.allocate(&gpu_allocator::vulkan::AllocationCreateDesc {
|
||||
name,
|
||||
requirements,
|
||||
location: gpu_allocator::MemoryLocation::CpuToGpu,
|
||||
linear: true,
|
||||
allocation_scheme: gpu_allocator::vulkan::AllocationScheme::GpuAllocatorManaged,
|
||||
})?;
|
||||
|
||||
unsafe {
|
||||
device.bind_buffer_memory(buffer, allocation.memory(), allocation.offset())?;
|
||||
}
|
||||
|
||||
let ptr = allocation
|
||||
.mapped_ptr()
|
||||
.ok_or(RendererError::NoSuitableGpu)?
|
||||
.as_ptr();
|
||||
|
||||
unsafe {
|
||||
std::ptr::copy_nonoverlapping(data.as_ptr(), ptr.cast(), data.len());
|
||||
}
|
||||
|
||||
Ok((buffer, allocation))
|
||||
}
|
||||
416
crates/renderer/src/meshing.rs
Normal file
416
crates/renderer/src/meshing.rs
Normal file
|
|
@ -0,0 +1,416 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
//! Cubic greedy mesher: converts a dense voxel [`Chunk`] into renderer geometry.
|
||||
|
||||
use crate::vertex::Vertex;
|
||||
use shared::world::{BlockId, CHUNK_SIZE, Chunk};
|
||||
|
||||
/// The signed direction a face points along one of the three axes.
|
||||
///
|
||||
/// The sign is part of the merge key: two faces on the same plane but pointing in opposite directions (for example a top face and the bottom face directly above it) must never merge, so `PosY` and `NegY` are distinct variants.
|
||||
#[derive(Copy, Clone, PartialEq, Eq)]
|
||||
enum FaceDir {
|
||||
/// Points toward increasing X.
|
||||
PosX,
|
||||
/// Points toward decreasing X.
|
||||
NegX,
|
||||
/// Points toward increasing Y (upward).
|
||||
PosY,
|
||||
/// Points toward decreasing Y (downward).
|
||||
NegY,
|
||||
/// Points toward increasing Z.
|
||||
PosZ,
|
||||
/// Points toward decreasing Z.
|
||||
NegZ,
|
||||
}
|
||||
|
||||
impl FaceDir {
|
||||
/// Returns the index identifying this direction's outward normal to the shader.
|
||||
///
|
||||
/// The six values are a contract with the `FACE_NORMALS` table in `assets/shaders/cube.vert`, which is indexed by them directly.
|
||||
const fn to_index(self) -> u32 {
|
||||
match self {
|
||||
Self::PosX => 0,
|
||||
Self::NegX => 1,
|
||||
Self::PosY => 2,
|
||||
Self::NegY => 3,
|
||||
Self::PosZ => 4,
|
||||
Self::NegZ => 5,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Identifies whether two faces are mergeable.
|
||||
#[derive(Copy, Clone, PartialEq, Eq)]
|
||||
struct FaceKey {
|
||||
/// The material of the voxel owning the face.
|
||||
block: BlockId,
|
||||
/// The face's signed axis direction, which selects its outward normal.
|
||||
dir: FaceDir,
|
||||
}
|
||||
|
||||
/// The flat RGB albedo emitted for every face.
|
||||
const MATERIAL_COLOR: [f32; 3] = [0.2, 0.8, 0.2];
|
||||
|
||||
/// Converts a chunk-local integer coordinate to its floating-point value.
|
||||
#[expect(
|
||||
clippy::cast_precision_loss,
|
||||
reason = "chunk-local coordinates never exceed CHUNK_SIZE (32) and are exact as f32"
|
||||
)]
|
||||
const fn coord(i: usize) -> f32 {
|
||||
i as f32
|
||||
}
|
||||
|
||||
/// The six face-adjacent neighbour chunks, if resident.
|
||||
///
|
||||
/// A `None` side means the neighbour is not loaded; that boundary is treated as exposed (its faces are emitted) so the load frontier shows no holes. The referenced chunks must outlive the [`Neighbors`] value, which is what the `'a` lifetime records.
|
||||
#[derive(Default)]
|
||||
pub struct Neighbors<'a> {
|
||||
/// Neighbour toward decreasing X, sampled at its `x = CHUNK_SIZE - 1` face.
|
||||
pub neg_x: Option<&'a Chunk>,
|
||||
/// Neighbour toward increasing X, sampled at its `x = 0` face.
|
||||
pub pos_x: Option<&'a Chunk>,
|
||||
/// Neighbour toward decreasing Y, sampled at its `y = CHUNK_SIZE - 1` face.
|
||||
pub neg_y: Option<&'a Chunk>,
|
||||
/// Neighbour toward increasing Y, sampled at its `y = 0` face.
|
||||
pub pos_y: Option<&'a Chunk>,
|
||||
/// Neighbour toward decreasing Z, sampled at its `z = CHUNK_SIZE - 1` face.
|
||||
pub neg_z: Option<&'a Chunk>,
|
||||
/// Neighbour toward increasing Z, sampled at its `z = 0` face.
|
||||
pub pos_z: Option<&'a Chunk>,
|
||||
}
|
||||
|
||||
/// Returns the block occluding the `dir` face of the voxel at (`x`, `y`, `z`).
|
||||
///
|
||||
/// When the adjacent voxel lies inside the chunk it is read directly. When it lies across the chunk boundary it is read from the matching entry of `neighbors` at the opposite edge; a `None` neighbour is treated as [`BlockId::AIR`] so the boundary face is emitted (frontier safety).
|
||||
fn occluder(
|
||||
chunk: &Chunk,
|
||||
neighbors: &Neighbors,
|
||||
x: usize,
|
||||
y: usize,
|
||||
z: usize,
|
||||
dir: FaceDir,
|
||||
) -> BlockId {
|
||||
const LAST: usize = CHUNK_SIZE - 1;
|
||||
match dir {
|
||||
FaceDir::PosX => {
|
||||
if x < LAST {
|
||||
chunk.get(x + 1, y, z)
|
||||
} else {
|
||||
neighbors.pos_x.map_or(BlockId::AIR, |c| c.get(0, y, z))
|
||||
}
|
||||
}
|
||||
FaceDir::NegX => {
|
||||
if x > 0 {
|
||||
chunk.get(x - 1, y, z)
|
||||
} else {
|
||||
neighbors.neg_x.map_or(BlockId::AIR, |c| c.get(LAST, y, z))
|
||||
}
|
||||
}
|
||||
FaceDir::PosY => {
|
||||
if y < LAST {
|
||||
chunk.get(x, y + 1, z)
|
||||
} else {
|
||||
neighbors.pos_y.map_or(BlockId::AIR, |c| c.get(x, 0, z))
|
||||
}
|
||||
}
|
||||
FaceDir::NegY => {
|
||||
if y > 0 {
|
||||
chunk.get(x, y - 1, z)
|
||||
} else {
|
||||
neighbors.neg_y.map_or(BlockId::AIR, |c| c.get(x, LAST, z))
|
||||
}
|
||||
}
|
||||
FaceDir::PosZ => {
|
||||
if z < LAST {
|
||||
chunk.get(x, y, z + 1)
|
||||
} else {
|
||||
neighbors.pos_z.map_or(BlockId::AIR, |c| c.get(x, y, 0))
|
||||
}
|
||||
}
|
||||
FaceDir::NegZ => {
|
||||
if z > 0 {
|
||||
chunk.get(x, y, z - 1)
|
||||
} else {
|
||||
neighbors.neg_z.map_or(BlockId::AIR, |c| c.get(x, y, LAST))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Meshes `chunk` into GPU vertices and triangle indices via greedy merging.
|
||||
///
|
||||
/// Each axis is swept slice by slice; on every slice a 2D mask of exposed faces over the two perpendicular axes is built and merged into rectangles. Boundary voxels are tested against `neighbors`: a chunk-edge face is emitted only when the adjoining voxel in the matching neighbour is air, or when that neighbour is absent (see [`Neighbors`]).
|
||||
#[must_use]
|
||||
#[expect(
|
||||
clippy::too_many_lines,
|
||||
reason = "six directional passes, each an inline sample + corners closure pair"
|
||||
)]
|
||||
pub fn generate_mesh(chunk: &Chunk, neighbors: &Neighbors) -> (Vec<Vertex>, Vec<u32>) {
|
||||
let mut vertices = Vec::new();
|
||||
let mut indices = Vec::new();
|
||||
// A single u×v mask, reused across every slice of every axis; each pass fully overwrites it per slice, so no explicit clearing is required.
|
||||
let mut mask = vec![None; CHUNK_SIZE * CHUNK_SIZE];
|
||||
|
||||
// +Y (top): slice = y, mask u = x, mask v = z.
|
||||
run_pass(
|
||||
&mut mask,
|
||||
&mut vertices,
|
||||
&mut indices,
|
||||
|y, x, z| {
|
||||
let block = chunk.get(x, y, z);
|
||||
(block != BlockId::AIR
|
||||
&& occluder(chunk, neighbors, x, y, z, FaceDir::PosY) == BlockId::AIR)
|
||||
.then_some(FaceKey {
|
||||
block,
|
||||
dir: FaceDir::PosY,
|
||||
})
|
||||
},
|
||||
|y, x0, z0, w, h| {
|
||||
let (xmin, xmax) = (coord(x0), coord(x0 + w));
|
||||
let (zmin, zmax) = (coord(z0), coord(z0 + h));
|
||||
let yp = coord(y + 1);
|
||||
[
|
||||
[xmin, yp, zmax],
|
||||
[xmax, yp, zmax],
|
||||
[xmax, yp, zmin],
|
||||
[xmin, yp, zmin],
|
||||
]
|
||||
},
|
||||
);
|
||||
|
||||
// -Y (bottom): slice = y, mask u = x, mask v = z.
|
||||
run_pass(
|
||||
&mut mask,
|
||||
&mut vertices,
|
||||
&mut indices,
|
||||
|y, x, z| {
|
||||
let block = chunk.get(x, y, z);
|
||||
(block != BlockId::AIR
|
||||
&& occluder(chunk, neighbors, x, y, z, FaceDir::NegY) == BlockId::AIR)
|
||||
.then_some(FaceKey {
|
||||
block,
|
||||
dir: FaceDir::NegY,
|
||||
})
|
||||
},
|
||||
|y, x0, z0, w, h| {
|
||||
let (xmin, xmax) = (coord(x0), coord(x0 + w));
|
||||
let (zmin, zmax) = (coord(z0), coord(z0 + h));
|
||||
let yp = coord(y);
|
||||
[
|
||||
[xmin, yp, zmin],
|
||||
[xmax, yp, zmin],
|
||||
[xmax, yp, zmax],
|
||||
[xmin, yp, zmax],
|
||||
]
|
||||
},
|
||||
);
|
||||
|
||||
// +X: slice = x, mask u = z, mask v = y.
|
||||
run_pass(
|
||||
&mut mask,
|
||||
&mut vertices,
|
||||
&mut indices,
|
||||
|x, z, y| {
|
||||
let block = chunk.get(x, y, z);
|
||||
(block != BlockId::AIR
|
||||
&& occluder(chunk, neighbors, x, y, z, FaceDir::PosX) == BlockId::AIR)
|
||||
.then_some(FaceKey {
|
||||
block,
|
||||
dir: FaceDir::PosX,
|
||||
})
|
||||
},
|
||||
|x, z0, y0, w, h| {
|
||||
let (zmin, zmax) = (coord(z0), coord(z0 + w));
|
||||
let (ymin, ymax) = (coord(y0), coord(y0 + h));
|
||||
let xp = coord(x + 1);
|
||||
[
|
||||
[xp, ymin, zmax],
|
||||
[xp, ymin, zmin],
|
||||
[xp, ymax, zmin],
|
||||
[xp, ymax, zmax],
|
||||
]
|
||||
},
|
||||
);
|
||||
|
||||
// -X: slice = x, mask u = z, mask v = y.
|
||||
run_pass(
|
||||
&mut mask,
|
||||
&mut vertices,
|
||||
&mut indices,
|
||||
|x, z, y| {
|
||||
let block = chunk.get(x, y, z);
|
||||
(block != BlockId::AIR
|
||||
&& occluder(chunk, neighbors, x, y, z, FaceDir::NegX) == BlockId::AIR)
|
||||
.then_some(FaceKey {
|
||||
block,
|
||||
dir: FaceDir::NegX,
|
||||
})
|
||||
},
|
||||
|x, z0, y0, w, h| {
|
||||
let (zmin, zmax) = (coord(z0), coord(z0 + w));
|
||||
let (ymin, ymax) = (coord(y0), coord(y0 + h));
|
||||
let xp = coord(x);
|
||||
[
|
||||
[xp, ymin, zmin],
|
||||
[xp, ymin, zmax],
|
||||
[xp, ymax, zmax],
|
||||
[xp, ymax, zmin],
|
||||
]
|
||||
},
|
||||
);
|
||||
|
||||
// +Z: slice = z, mask u = x, mask v = y.
|
||||
run_pass(
|
||||
&mut mask,
|
||||
&mut vertices,
|
||||
&mut indices,
|
||||
|z, x, y| {
|
||||
let block = chunk.get(x, y, z);
|
||||
(block != BlockId::AIR
|
||||
&& occluder(chunk, neighbors, x, y, z, FaceDir::PosZ) == BlockId::AIR)
|
||||
.then_some(FaceKey {
|
||||
block,
|
||||
dir: FaceDir::PosZ,
|
||||
})
|
||||
},
|
||||
|z, x0, y0, w, h| {
|
||||
let (xmin, xmax) = (coord(x0), coord(x0 + w));
|
||||
let (ymin, ymax) = (coord(y0), coord(y0 + h));
|
||||
let zp = coord(z + 1);
|
||||
[
|
||||
[xmin, ymin, zp],
|
||||
[xmax, ymin, zp],
|
||||
[xmax, ymax, zp],
|
||||
[xmin, ymax, zp],
|
||||
]
|
||||
},
|
||||
);
|
||||
|
||||
// -Z: slice = z, mask u = x, mask v = y.
|
||||
run_pass(
|
||||
&mut mask,
|
||||
&mut vertices,
|
||||
&mut indices,
|
||||
|z, x, y| {
|
||||
let block = chunk.get(x, y, z);
|
||||
(block != BlockId::AIR
|
||||
&& occluder(chunk, neighbors, x, y, z, FaceDir::NegZ) == BlockId::AIR)
|
||||
.then_some(FaceKey {
|
||||
block,
|
||||
dir: FaceDir::NegZ,
|
||||
})
|
||||
},
|
||||
|z, x0, y0, w, h| {
|
||||
let (xmin, xmax) = (coord(x0), coord(x0 + w));
|
||||
let (ymin, ymax) = (coord(y0), coord(y0 + h));
|
||||
let zp = coord(z);
|
||||
[
|
||||
[xmax, ymin, zp],
|
||||
[xmin, ymin, zp],
|
||||
[xmin, ymax, zp],
|
||||
[xmax, ymax, zp],
|
||||
]
|
||||
},
|
||||
);
|
||||
|
||||
(vertices, indices)
|
||||
}
|
||||
|
||||
/// Runs one directional meshing pass over all `CHUNK_SIZE` slices.
|
||||
///
|
||||
/// `sample(slice, u, v)` returns the [`FaceKey`] for the face at mask cell `(u, v)` of `slice`, or `None` when no face is exposed there. `corners(slice, u0, v0, w, h)` yields the four world-space corners, ordered counter-clockwise as seen from outside the face, of a merged rectangle rooted at `(u0, v0)` with width `w` along `u` and height `h` along `v`.
|
||||
fn run_pass(
|
||||
mask: &mut [Option<FaceKey>],
|
||||
vertices: &mut Vec<Vertex>,
|
||||
indices: &mut Vec<u32>,
|
||||
mut sample: impl FnMut(usize, usize, usize) -> Option<FaceKey>,
|
||||
corners: impl Fn(usize, usize, usize, usize, usize) -> [[f32; 3]; 4],
|
||||
) {
|
||||
for slice in 0..CHUNK_SIZE {
|
||||
for v in 0..CHUNK_SIZE {
|
||||
for u in 0..CHUNK_SIZE {
|
||||
mask[u + v * CHUNK_SIZE] = sample(slice, u, v);
|
||||
}
|
||||
}
|
||||
|
||||
merge_mask(mask, |key, u0, v0, w, h| {
|
||||
push_quad(
|
||||
vertices,
|
||||
indices,
|
||||
corners(slice, u0, v0, w, h),
|
||||
key.dir.to_index(),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Greedily covers the exposed cells of `mask` with maximal rectangles.
|
||||
///
|
||||
/// Cells are scanned row-major. At the first exposed, unconsumed cell the run is extended along `u` while the key matches, then along `v` while every cell of the next row over the current width matches. The covered cells are marked consumed (set to `None`) so they are not re-emitted, and `emit(key, u0, v0, w, h)` is called once for the rectangle.
|
||||
fn merge_mask(
|
||||
mask: &mut [Option<FaceKey>],
|
||||
mut emit: impl FnMut(FaceKey, usize, usize, usize, usize),
|
||||
) {
|
||||
for v in 0..CHUNK_SIZE {
|
||||
for u in 0..CHUNK_SIZE {
|
||||
let Some(key) = mask[u + v * CHUNK_SIZE] else {
|
||||
continue;
|
||||
};
|
||||
|
||||
// Extend width along u while the key is unbroken.
|
||||
let mut w = 1;
|
||||
while u + w < CHUNK_SIZE && mask[(u + w) + v * CHUNK_SIZE] == Some(key) {
|
||||
w += 1;
|
||||
}
|
||||
|
||||
// Extend height along v while every cell of the next row matches over [0, w).
|
||||
let mut h = 1;
|
||||
'grow: while v + h < CHUNK_SIZE {
|
||||
for du in 0..w {
|
||||
if mask[(u + du) + (v + h) * CHUNK_SIZE] != Some(key) {
|
||||
break 'grow;
|
||||
}
|
||||
}
|
||||
h += 1;
|
||||
}
|
||||
|
||||
// Consume the covered rectangle so its cells are not re-emitted.
|
||||
for dv in 0..h {
|
||||
for du in 0..w {
|
||||
mask[(u + du) + (v + dv) * CHUNK_SIZE] = None;
|
||||
}
|
||||
}
|
||||
|
||||
emit(key, u, v, w, h);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Appends one quad (four vertices, six indices) with the given corners and packed face normal.
|
||||
///
|
||||
/// A quad is planar, so all four vertices share `normal`. Indices wind the two triangles as `[base, base+1, base+2, base+2, base+3, base]`, matching the corner ordering supplied by the caller.
|
||||
fn push_quad(
|
||||
vertices: &mut Vec<Vertex>,
|
||||
indices: &mut Vec<u32>,
|
||||
corners: [[f32; 3]; 4],
|
||||
normal: u32,
|
||||
) {
|
||||
#[expect(
|
||||
clippy::cast_possible_truncation,
|
||||
reason = "a chunk mesh holds far fewer than u32::MAX vertices"
|
||||
)]
|
||||
let base = vertices.len() as u32;
|
||||
for position in corners {
|
||||
vertices.push(Vertex {
|
||||
position,
|
||||
color: MATERIAL_COLOR,
|
||||
normal,
|
||||
});
|
||||
}
|
||||
indices.extend_from_slice(&[base, base + 1, base + 2, base + 2, base + 3, base]);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tests/meshing.rs"]
|
||||
mod tests;
|
||||
198
crates/renderer/src/pipeline.rs
Normal file
198
crates/renderer/src/pipeline.rs
Normal file
|
|
@ -0,0 +1,198 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
//! Graphics pipeline creation and shader management.
|
||||
|
||||
use crate::error::RendererError;
|
||||
use crate::vertex::Vertex;
|
||||
use ash::{Device, vk};
|
||||
use std::io::Cursor;
|
||||
|
||||
/// Helper to load SPIR-V bytes and create a Vulkan Shader Module.
|
||||
///
|
||||
/// Vulkan expects shader code to be 32-bit aligned; `ash::util::read_spv` is used to correctly interpret the raw bytes as a slice of `u32`.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`RendererError::IoError`] if `bytes` is not valid, 32-bit-aligned SPIR-V, or [`RendererError::VulkanError`] if module creation fails on the device.
|
||||
pub fn create_shader_module(
|
||||
device: &Device,
|
||||
bytes: &[u8],
|
||||
) -> Result<vk::ShaderModule, RendererError> {
|
||||
let mut cursor = Cursor::new(bytes);
|
||||
|
||||
let code = ash::util::read_spv(&mut cursor)?;
|
||||
|
||||
let create_info = vk::ShaderModuleCreateInfo::default().code(&code);
|
||||
|
||||
let module = unsafe { device.create_shader_module(&create_info, None)? };
|
||||
Ok(module)
|
||||
}
|
||||
|
||||
/// Size, in bytes, of one `vec4` slot of the push-constant block.
|
||||
pub const VEC4_BYTES: u32 = 16;
|
||||
|
||||
/// Number of `vec4` slots following the MVP matrix in the push-constant block: the per-chunk offset, the fog parameters, and the sky colour.
|
||||
const PUSH_CONSTANT_VEC4S: u32 = 3;
|
||||
|
||||
/// Shader stages that read the push-constant block.
|
||||
///
|
||||
/// Both stages are declared across the entire range: the vertex stage consumes the MVP and the per-chunk offset, the fragment stage the fog and sky slots. Vulkan requires the `stage_flags` given to every `cmd_push_constants` call to cover exactly the stages the layout declares for the bytes being written, so the layout and every update read this one value rather than restating the flags.
|
||||
pub const PUSH_CONSTANT_STAGES: vk::ShaderStageFlags = vk::ShaderStageFlags::from_raw(
|
||||
vk::ShaderStageFlags::VERTEX.as_raw() | vk::ShaderStageFlags::FRAGMENT.as_raw(),
|
||||
);
|
||||
|
||||
/// Defines the 'interface' of the pipeline (what data we can pass to the shaders).
|
||||
///
|
||||
/// This layout defines any push constants or descriptor sets (textures/UBOs) accessed by the shaders during execution.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`RendererError::VulkanError`] if the device fails to create the pipeline layout.
|
||||
pub fn create_pipeline_layout(device: &Device) -> Result<vk::PipelineLayout, RendererError> {
|
||||
// The push-constant range covers the 64-byte MVP matrix followed by three 16-byte vec4 slots (112 bytes total, within the 128-byte guaranteed minimum).
|
||||
#[expect(
|
||||
clippy::expect_used,
|
||||
reason = "112 bytes (Mat4 + three vec4s) is well within u32 range"
|
||||
)]
|
||||
let push_constant_range = vk::PushConstantRange::default()
|
||||
.stage_flags(PUSH_CONSTANT_STAGES)
|
||||
.offset(0)
|
||||
.size(
|
||||
u32::try_from(std::mem::size_of::<glam::Mat4>())
|
||||
.map(|mvp| mvp + PUSH_CONSTANT_VEC4S * VEC4_BYTES)
|
||||
.expect("push-constant size exceeds u32 range"),
|
||||
);
|
||||
|
||||
let layout_create_info = vk::PipelineLayoutCreateInfo::default()
|
||||
.push_constant_ranges(std::slice::from_ref(&push_constant_range));
|
||||
|
||||
let layout = unsafe { device.create_pipeline_layout(&layout_create_info, None)? };
|
||||
Ok(layout)
|
||||
}
|
||||
|
||||
/// Creates a Graphics Pipeline for voxel rendering using Vulkan 1.3 Dynamic Rendering.
|
||||
///
|
||||
/// The pipeline encapsulates the entire state of the GPU for a specific draw operation, including shader stages, vertex input layout, rasterization settings, and blending.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`RendererError::InvalidString`] if the shader entry-point name cannot be built, [`RendererError::IoError`] if an embedded shader is not valid SPIR-V, or [`RendererError::VulkanError`] if shader-module or pipeline creation fails on the device.
|
||||
pub fn create_graphics_pipeline(
|
||||
device: &Device,
|
||||
layout: vk::PipelineLayout,
|
||||
color_format: vk::Format,
|
||||
polygon_mode: vk::PolygonMode,
|
||||
depth_compare_op: vk::CompareOp,
|
||||
) -> Result<vk::Pipeline, RendererError> {
|
||||
// 1. Load and compile shader modules
|
||||
let (vert_module, frag_module) = load_shader_modules(device)?;
|
||||
let entry_point = std::ffi::CString::new("main").map_err(|_| RendererError::InvalidString)?;
|
||||
|
||||
let shader_stages = [
|
||||
vk::PipelineShaderStageCreateInfo::default()
|
||||
.stage(vk::ShaderStageFlags::VERTEX)
|
||||
.module(vert_module)
|
||||
.name(&entry_point),
|
||||
vk::PipelineShaderStageCreateInfo::default()
|
||||
.stage(vk::ShaderStageFlags::FRAGMENT)
|
||||
.module(frag_module)
|
||||
.name(&entry_point),
|
||||
];
|
||||
|
||||
// 2. Configure Fixed-Function States
|
||||
let binding_descriptions = [Vertex::get_binding_description()];
|
||||
let attribute_descriptions = Vertex::get_attribute_descriptions();
|
||||
let vertex_input_info = vk::PipelineVertexInputStateCreateInfo::default()
|
||||
.vertex_binding_descriptions(&binding_descriptions)
|
||||
.vertex_attribute_descriptions(&attribute_descriptions);
|
||||
|
||||
let input_assembly = vk::PipelineInputAssemblyStateCreateInfo::default()
|
||||
.topology(vk::PrimitiveTopology::TRIANGLE_LIST)
|
||||
.primitive_restart_enable(false);
|
||||
|
||||
let viewport_state = vk::PipelineViewportStateCreateInfo::default()
|
||||
.viewport_count(1)
|
||||
.scissor_count(1);
|
||||
|
||||
let rasterizer = vk::PipelineRasterizationStateCreateInfo::default()
|
||||
.depth_clamp_enable(false)
|
||||
.rasterizer_discard_enable(false)
|
||||
.polygon_mode(polygon_mode)
|
||||
.line_width(1.0)
|
||||
.cull_mode(vk::CullModeFlags::BACK)
|
||||
.front_face(vk::FrontFace::COUNTER_CLOCKWISE)
|
||||
.depth_bias_enable(false);
|
||||
|
||||
let multisampling = vk::PipelineMultisampleStateCreateInfo::default()
|
||||
.sample_shading_enable(false)
|
||||
.rasterization_samples(vk::SampleCountFlags::TYPE_1);
|
||||
|
||||
let color_blend_attachment = vk::PipelineColorBlendAttachmentState::default()
|
||||
.color_write_mask(vk::ColorComponentFlags::RGBA)
|
||||
.blend_enable(false);
|
||||
|
||||
let color_blending = vk::PipelineColorBlendStateCreateInfo::default()
|
||||
.logic_op_enable(false)
|
||||
.attachments(std::slice::from_ref(&color_blend_attachment));
|
||||
|
||||
let dynamic_states = [vk::DynamicState::VIEWPORT, vk::DynamicState::SCISSOR];
|
||||
let dynamic_state_info =
|
||||
vk::PipelineDynamicStateCreateInfo::default().dynamic_states(&dynamic_states);
|
||||
|
||||
let color_formats = [color_format];
|
||||
let mut rendering_info = vk::PipelineRenderingCreateInfo::default()
|
||||
.color_attachment_formats(&color_formats)
|
||||
.depth_attachment_format(vk::Format::D32_SFLOAT);
|
||||
|
||||
let depth_stencil_state = &vk::PipelineDepthStencilStateCreateInfo::default()
|
||||
.depth_test_enable(true)
|
||||
.depth_write_enable(true)
|
||||
.depth_compare_op(depth_compare_op)
|
||||
.depth_bounds_test_enable(false)
|
||||
.stencil_test_enable(false);
|
||||
|
||||
// 3. Finalize Pipeline Creation
|
||||
let pipeline_info = vk::GraphicsPipelineCreateInfo::default()
|
||||
.push_next(&mut rendering_info)
|
||||
.stages(&shader_stages)
|
||||
.vertex_input_state(&vertex_input_info)
|
||||
.input_assembly_state(&input_assembly)
|
||||
.viewport_state(&viewport_state)
|
||||
.rasterization_state(&rasterizer)
|
||||
.multisample_state(&multisampling)
|
||||
.color_blend_state(&color_blending)
|
||||
.dynamic_state(&dynamic_state_info)
|
||||
.layout(layout)
|
||||
.depth_stencil_state(depth_stencil_state);
|
||||
|
||||
let result = unsafe {
|
||||
device.create_graphics_pipelines(vk::PipelineCache::null(), &[pipeline_info], None)
|
||||
};
|
||||
|
||||
unsafe {
|
||||
device.destroy_shader_module(vert_module, None);
|
||||
device.destroy_shader_module(frag_module, None);
|
||||
}
|
||||
|
||||
let pipeline = result.map_err(|(_, e)| e)?[0];
|
||||
Ok(pipeline)
|
||||
}
|
||||
|
||||
/// Loads the vertex and fragment shader modules from embedded bytes.
|
||||
///
|
||||
/// The SPIR-V is produced from the GLSL sources by the crate's build script and embedded from `OUT_DIR`, so the modules always correspond to the shader sources present at compile time.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`RendererError::IoError`] if an embedded shader is not valid SPIR-V, or [`RendererError::VulkanError`] if module creation fails on the device.
|
||||
fn load_shader_modules(
|
||||
device: &Device,
|
||||
) -> Result<(vk::ShaderModule, vk::ShaderModule), RendererError> {
|
||||
let vert_bytes = include_bytes!(concat!(env!("OUT_DIR"), "/cube.vert.spv"));
|
||||
let frag_bytes = include_bytes!(concat!(env!("OUT_DIR"), "/cube.frag.spv"));
|
||||
|
||||
let vert_module = create_shader_module(device, vert_bytes)?;
|
||||
let frag_module = create_shader_module(device, frag_bytes)?;
|
||||
|
||||
Ok((vert_module, frag_module))
|
||||
}
|
||||
1055
crates/renderer/src/renderer.rs
Normal file
1055
crates/renderer/src/renderer.rs
Normal file
File diff suppressed because it is too large
Load diff
160
crates/renderer/src/stats.rs
Normal file
160
crates/renderer/src/stats.rs
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
//! Snapshot types describing what the renderer submitted and what device it submitted to.
|
||||
|
||||
use crate::renderer::RenderMode;
|
||||
|
||||
/// The projection parameters used to build this frame's perspective matrix.
|
||||
#[derive(Copy, Clone, Debug, PartialEq)]
|
||||
pub struct ProjectionInfo {
|
||||
/// Vertical field of view, in radians.
|
||||
pub fov_y_radians: f32,
|
||||
/// Width-to-height ratio of the render target, derived from the swapchain extent.
|
||||
pub aspect: f32,
|
||||
/// Distance to the near clip plane, in blocks.
|
||||
pub near: f32,
|
||||
/// Distance to the far clip plane, in blocks.
|
||||
pub far: f32,
|
||||
}
|
||||
|
||||
/// Description of the swapchain currently backing presentation.
|
||||
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
|
||||
pub struct SwapchainInfo {
|
||||
/// Number of images the swapchain was created with.
|
||||
pub image_count: u32,
|
||||
/// Width of the swapchain images, in pixels.
|
||||
pub width: u32,
|
||||
/// Height of the swapchain images, in pixels.
|
||||
pub height: u32,
|
||||
/// Presentation mode the swapchain was created with, rendered as its Vulkan enum name.
|
||||
pub present_mode: &'static str,
|
||||
}
|
||||
|
||||
/// What the renderer submitted for one frame, plus the cumulative frame counters.
|
||||
///
|
||||
/// Populated at the end of every successful [`Renderer::draw_frame`](crate::Renderer::draw_frame) and retained until the next frame replaces it, so a reader running on a slower cadence than the render loop always observes a complete, self-consistent frame rather than a partially-updated one.
|
||||
#[derive(Copy, Clone, Debug, PartialEq)]
|
||||
pub struct RenderStats {
|
||||
/// Total chunk meshes currently uploaded to the GPU, visible or not.
|
||||
pub uploaded_meshes: usize,
|
||||
/// Meshes that survived frustum culling and were submitted this frame.
|
||||
pub visible_meshes: usize,
|
||||
/// Meshes rejected by frustum culling this frame.
|
||||
pub culled_meshes: usize,
|
||||
/// Indexed draw calls recorded this frame: one per visible mesh per raster pass.
|
||||
pub draw_calls: usize,
|
||||
/// Triangles submitted this frame, counted across every pass.
|
||||
pub triangles: u64,
|
||||
/// Vertices referenced by the submitted meshes, counted across every pass.
|
||||
pub vertices: u64,
|
||||
/// Bytes of vertex buffer held by every uploaded mesh, visible or not.
|
||||
pub vertex_bytes: u64,
|
||||
/// Bytes of index buffer held by every uploaded mesh, visible or not.
|
||||
pub index_bytes: u64,
|
||||
/// The render mode in force this frame, which determines the pass list and therefore the draw-call multiplier.
|
||||
pub render_mode: RenderMode,
|
||||
/// Projection parameters used to build this frame's matrix.
|
||||
pub projection: ProjectionInfo,
|
||||
/// The swapchain backing presentation at the end of this frame.
|
||||
pub swapchain: SwapchainInfo,
|
||||
/// Frames presented since renderer initialisation.
|
||||
pub frames_presented: u64,
|
||||
/// Frames abandoned before submission because the swapchain reported itself out of date, typically during a window resize.
|
||||
pub frames_skipped: u64,
|
||||
}
|
||||
|
||||
impl RenderStats {
|
||||
/// Returns the fraction of uploaded meshes rejected by frustum culling this frame, in percent.
|
||||
///
|
||||
/// Returns zero when nothing was uploaded, since no meshes means no meshes were culled rather than an undefined ratio.
|
||||
#[must_use]
|
||||
pub fn cull_ratio_percent(&self) -> f32 {
|
||||
let considered = self.visible_meshes + self.culled_meshes;
|
||||
if considered == 0 {
|
||||
return 0.0;
|
||||
}
|
||||
// Mesh counts are bounded by the resident chunk set (thousands), far inside f32's exact-integer range.
|
||||
#[expect(
|
||||
clippy::cast_precision_loss,
|
||||
reason = "mesh counts stay well within f32's exact-integer range"
|
||||
)]
|
||||
{
|
||||
self.culled_meshes as f32 / considered as f32 * 100.0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Immutable description of the physical device the renderer selected.
|
||||
///
|
||||
/// Queried once at initialisation: every field is a property of the device or driver and cannot change for the lifetime of the renderer. Live memory figures are not part of this and are read separately through [`MemoryUsage`].
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct GpuInfo {
|
||||
/// Marketing name the driver reports for the device.
|
||||
pub device_name: String,
|
||||
/// Device class: discrete, integrated, virtual, CPU, or other.
|
||||
pub device_type: &'static str,
|
||||
/// PCI vendor identifier, as reported by the driver.
|
||||
pub vendor_id: u32,
|
||||
/// Vendor-assigned device identifier.
|
||||
pub device_id: u32,
|
||||
/// Driver version, decoded with the vendor's own packing scheme where it differs from the Vulkan convention.
|
||||
pub driver_version: String,
|
||||
/// Vulkan API version the device supports, as `major.minor.patch`.
|
||||
pub api_version: String,
|
||||
/// Total capacity of every heap flagged `DEVICE_LOCAL`, in bytes. This is dedicated video memory on a discrete device and a share of system memory on an integrated one.
|
||||
pub vram_total_bytes: u64,
|
||||
/// Whether `VK_EXT_memory_budget` was available and enabled, and therefore whether [`MemoryUsage`] can report driver-side figures.
|
||||
pub memory_budget_supported: bool,
|
||||
}
|
||||
|
||||
/// Live memory figures, read on demand rather than cached.
|
||||
///
|
||||
/// Two independent views: the driver's own accounting of the device-local heaps (available only where `VK_EXT_memory_budget` is supported) and the renderer's allocator, which sees only what this process suballocates.
|
||||
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
|
||||
pub struct MemoryUsage {
|
||||
/// Bytes the driver reports as currently in use across the device-local heaps, by every process. [`None`] where `VK_EXT_memory_budget` is unsupported.
|
||||
pub heap_usage_bytes: Option<u64>,
|
||||
/// Bytes the driver reports this process may use across the device-local heaps before it risks eviction. [`None`] where `VK_EXT_memory_budget` is unsupported.
|
||||
pub heap_budget_bytes: Option<u64>,
|
||||
/// Bytes currently handed out by the renderer's allocator as live suballocations.
|
||||
pub allocator_allocated_bytes: u64,
|
||||
/// Bytes the renderer's allocator holds in device memory blocks, including regions not yet suballocated. Always at least `allocator_allocated_bytes`; the difference is allocator slack.
|
||||
pub allocator_capacity_bytes: u64,
|
||||
}
|
||||
|
||||
/// PCI vendor identifier for NVIDIA, whose driver packs `driver_version` differently from the Vulkan convention.
|
||||
const VENDOR_NVIDIA: u32 = 0x10DE;
|
||||
|
||||
/// PCI vendor identifier for Intel, whose Windows driver packs `driver_version` differently from the Vulkan convention.
|
||||
const VENDOR_INTEL: u32 = 0x8086;
|
||||
|
||||
/// Decodes a `VkPhysicalDeviceProperties::driverVersion` into a human-readable string.
|
||||
///
|
||||
/// The field is documented as vendor-specific, and two vendors deviate from the `VK_MAKE_VERSION` packing the rest follow. NVIDIA uses a four-component 10/8/8/6-bit layout. Intel's Windows driver uses a 14/18-bit split; its Linux (Mesa) driver follows the Vulkan convention, so the deviation is applied only on Windows. Every other vendor is decoded as major/minor/patch.
|
||||
#[must_use]
|
||||
pub fn decode_driver_version(vendor_id: u32, version: u32) -> String {
|
||||
if vendor_id == VENDOR_NVIDIA {
|
||||
return format!(
|
||||
"{}.{}.{}.{}",
|
||||
(version >> 22) & 0x3ff,
|
||||
(version >> 14) & 0x0ff,
|
||||
(version >> 6) & 0x0ff,
|
||||
version & 0x3f
|
||||
);
|
||||
}
|
||||
|
||||
if vendor_id == VENDOR_INTEL && cfg!(windows) {
|
||||
return format!("{}.{}", version >> 14, version & 0x3fff);
|
||||
}
|
||||
|
||||
format!(
|
||||
"{}.{}.{}",
|
||||
version >> 22,
|
||||
(version >> 12) & 0x3ff,
|
||||
version & 0xfff
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tests/stats.rs"]
|
||||
mod tests;
|
||||
24
crates/renderer/src/surface.rs
Normal file
24
crates/renderer/src/surface.rs
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
use crate::error::RendererError;
|
||||
use ash::{Entry, Instance, khr, vk};
|
||||
use raw_window_handle::{RawDisplayHandle, RawWindowHandle};
|
||||
|
||||
/// Creates a Vulkan surface for the given window.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`RendererError::VulkanError`] if the platform surface cannot be created for the given display and window handles.
|
||||
pub fn create_surface(
|
||||
entry: &Entry,
|
||||
instance: &Instance,
|
||||
display_handle: RawDisplayHandle,
|
||||
window_handle: RawWindowHandle,
|
||||
) -> Result<(khr::surface::Instance, vk::SurfaceKHR), RendererError> {
|
||||
let surface = unsafe {
|
||||
ash_window::create_surface(entry, instance, display_handle, window_handle, None)?
|
||||
};
|
||||
let surface_loader = khr::surface::Instance::new(entry, instance);
|
||||
|
||||
Ok((surface_loader, surface))
|
||||
}
|
||||
145
crates/renderer/src/swapchain.rs
Normal file
145
crates/renderer/src/swapchain.rs
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
use crate::error::RendererError;
|
||||
use ash::{Device, Instance, khr, vk};
|
||||
|
||||
/// Presentation mode every swapchain is created with.
|
||||
///
|
||||
/// `FIFO` is the only mode the specification guarantees to be supported, and it is vsync-locked, so presentation never tears.
|
||||
// TODO: select from the surface's supported modes once a vsync setting exists; `MAILBOX` is the low-latency alternative where available.
|
||||
pub const PRESENT_MODE: vk::PresentModeKHR = vk::PresentModeKHR::FIFO;
|
||||
|
||||
/// Returns the Vulkan enum name of a presentation mode, for reporting.
|
||||
///
|
||||
/// A mode outside the known set is reported as `"UNKNOWN"` rather than its numeric value, since the numeric value carries no meaning to a reader.
|
||||
#[must_use]
|
||||
pub const fn present_mode_name(mode: vk::PresentModeKHR) -> &'static str {
|
||||
match mode {
|
||||
vk::PresentModeKHR::IMMEDIATE => "IMMEDIATE",
|
||||
vk::PresentModeKHR::MAILBOX => "MAILBOX",
|
||||
vk::PresentModeKHR::FIFO => "FIFO",
|
||||
vk::PresentModeKHR::FIFO_RELAXED => "FIFO_RELAXED",
|
||||
_ => "UNKNOWN",
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a swapchain and retrieves its images.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`RendererError::VulkanError`] if a surface query fails or the swapchain and its images cannot be created.
|
||||
///
|
||||
/// # Panics
|
||||
///
|
||||
/// Panics if the driver reports zero surface formats, which the Vulkan specification forbids for a supported surface.
|
||||
pub fn create_swapchain(
|
||||
instance: &Instance,
|
||||
physical_device: vk::PhysicalDevice,
|
||||
device: &Device,
|
||||
surface_loader: &khr::surface::Instance,
|
||||
surface: vk::SurfaceKHR,
|
||||
width: u32,
|
||||
height: u32,
|
||||
) -> Result<
|
||||
(
|
||||
khr::swapchain::Device,
|
||||
vk::SwapchainKHR,
|
||||
Vec<vk::Image>,
|
||||
vk::Format,
|
||||
vk::Extent2D,
|
||||
),
|
||||
RendererError,
|
||||
> {
|
||||
let surface_capabilities = unsafe {
|
||||
surface_loader.get_physical_device_surface_capabilities(physical_device, surface)?
|
||||
};
|
||||
let surface_formats =
|
||||
unsafe { surface_loader.get_physical_device_surface_formats(physical_device, surface)? };
|
||||
let _surface_present_modes = unsafe {
|
||||
surface_loader.get_physical_device_surface_present_modes(physical_device, surface)?
|
||||
};
|
||||
|
||||
let format = surface_formats
|
||||
.iter()
|
||||
.find(|f| {
|
||||
f.format == vk::Format::B8G8R8A8_SRGB
|
||||
&& f.color_space == vk::ColorSpaceKHR::SRGB_NONLINEAR
|
||||
})
|
||||
.unwrap_or(&surface_formats[0]);
|
||||
|
||||
let extent = if surface_capabilities.current_extent.width == u32::MAX {
|
||||
vk::Extent2D {
|
||||
width: width.clamp(
|
||||
surface_capabilities.min_image_extent.width,
|
||||
surface_capabilities.max_image_extent.width,
|
||||
),
|
||||
height: height.clamp(
|
||||
surface_capabilities.min_image_extent.height,
|
||||
surface_capabilities.max_image_extent.height,
|
||||
),
|
||||
}
|
||||
} else {
|
||||
surface_capabilities.current_extent
|
||||
};
|
||||
|
||||
let image_count = if surface_capabilities.max_image_count > 0
|
||||
&& surface_capabilities.min_image_count + 1 > surface_capabilities.max_image_count
|
||||
{
|
||||
surface_capabilities.max_image_count
|
||||
} else {
|
||||
surface_capabilities.min_image_count + 1
|
||||
};
|
||||
|
||||
let swapchain_loader = khr::swapchain::Device::new(instance, device);
|
||||
|
||||
let create_info = vk::SwapchainCreateInfoKHR::default()
|
||||
.surface(surface)
|
||||
.min_image_count(image_count)
|
||||
.image_format(format.format)
|
||||
.image_color_space(format.color_space)
|
||||
.image_extent(extent)
|
||||
.image_array_layers(1)
|
||||
.image_usage(vk::ImageUsageFlags::COLOR_ATTACHMENT)
|
||||
.image_sharing_mode(vk::SharingMode::EXCLUSIVE)
|
||||
.pre_transform(surface_capabilities.current_transform)
|
||||
.composite_alpha(vk::CompositeAlphaFlagsKHR::OPAQUE)
|
||||
.present_mode(PRESENT_MODE)
|
||||
.clipped(true);
|
||||
|
||||
let swapchain = unsafe { swapchain_loader.create_swapchain(&create_info, None)? };
|
||||
let images = unsafe { swapchain_loader.get_swapchain_images(swapchain)? };
|
||||
|
||||
Ok((swapchain_loader, swapchain, images, format.format, extent))
|
||||
}
|
||||
|
||||
/// Creates image views for the swapchain images.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`RendererError::VulkanError`] if the device fails to create an image view.
|
||||
pub fn create_image_views(
|
||||
device: &Device,
|
||||
images: &[vk::Image],
|
||||
format: vk::Format,
|
||||
) -> Result<Vec<vk::ImageView>, RendererError> {
|
||||
let mut views = Vec::with_capacity(images.len());
|
||||
|
||||
for &image in images {
|
||||
let create_info = vk::ImageViewCreateInfo::default()
|
||||
.image(image)
|
||||
.view_type(vk::ImageViewType::TYPE_2D)
|
||||
.format(format)
|
||||
.subresource_range(vk::ImageSubresourceRange {
|
||||
aspect_mask: vk::ImageAspectFlags::COLOR,
|
||||
base_mip_level: 0,
|
||||
level_count: 1,
|
||||
base_array_layer: 0,
|
||||
layer_count: 1,
|
||||
});
|
||||
|
||||
let view = unsafe { device.create_image_view(&create_info, None)? };
|
||||
views.push(view);
|
||||
}
|
||||
|
||||
Ok(views)
|
||||
}
|
||||
66
crates/renderer/src/sync.rs
Normal file
66
crates/renderer/src/sync.rs
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
use crate::error::RendererError;
|
||||
use ash::{Device, vk};
|
||||
|
||||
/// Groups all synchronization primitives for the renderer.
|
||||
pub struct SyncPrimitives {
|
||||
/// Semaphores signaled when an image has been acquired from the swapchain and is ready for rendering.
|
||||
pub image_available: Vec<vk::Semaphore>,
|
||||
/// Semaphores signaled when rendering to a swapchain image is complete.
|
||||
pub render_finished: Vec<vk::Semaphore>,
|
||||
/// Fences used to synchronize CPU execution with GPU frame completion.
|
||||
pub in_flight: Vec<vk::Fence>,
|
||||
}
|
||||
|
||||
/// Creates all synchronization primitives for the given number of frames and images.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`RendererError::VulkanError`] if the device fails to create a semaphore or fence.
|
||||
pub fn create_sync_primitives(
|
||||
device: &Device,
|
||||
max_frames_in_flight: usize,
|
||||
image_count: usize,
|
||||
) -> Result<SyncPrimitives, RendererError> {
|
||||
let semaphore_info = vk::SemaphoreCreateInfo::default();
|
||||
let fence_info = vk::FenceCreateInfo::default().flags(vk::FenceCreateFlags::SIGNALED);
|
||||
|
||||
let mut image_available = Vec::with_capacity(max_frames_in_flight);
|
||||
let mut render_finished = Vec::with_capacity(image_count);
|
||||
let mut in_flight = Vec::with_capacity(max_frames_in_flight);
|
||||
|
||||
for _ in 0..max_frames_in_flight {
|
||||
image_available.push(unsafe { device.create_semaphore(&semaphore_info, None)? });
|
||||
in_flight.push(unsafe { device.create_fence(&fence_info, None)? });
|
||||
}
|
||||
|
||||
for _ in 0..image_count {
|
||||
render_finished.push(unsafe { device.create_semaphore(&semaphore_info, None)? });
|
||||
}
|
||||
|
||||
Ok(SyncPrimitives {
|
||||
image_available,
|
||||
render_finished,
|
||||
in_flight,
|
||||
})
|
||||
}
|
||||
|
||||
/// Destroys all synchronization primitives.
|
||||
///
|
||||
/// # Safety
|
||||
///
|
||||
/// The caller must ensure every primitive in `sync` was created from `device`, is no longer in use by any in-flight GPU work, and is not destroyed again.
|
||||
pub unsafe fn destroy_sync_primitives(device: &Device, sync: SyncPrimitives) {
|
||||
unsafe {
|
||||
for semaphore in sync.image_available {
|
||||
device.destroy_semaphore(semaphore, None);
|
||||
}
|
||||
for semaphore in sync.render_finished {
|
||||
device.destroy_semaphore(semaphore, None);
|
||||
}
|
||||
for fence in sync.in_flight {
|
||||
device.destroy_fence(fence, None);
|
||||
}
|
||||
}
|
||||
}
|
||||
39
crates/renderer/src/tests/frustum.rs
Normal file
39
crates/renderer/src/tests/frustum.rs
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
//! Unit tests for view-frustum culling in [`crate::frustum`].
|
||||
|
||||
use super::Frustum;
|
||||
use glam::Vec3;
|
||||
|
||||
/// Builds a frustum for a camera at the origin looking down the -Z axis, matching the engine's right-handed Vulkan-clip projection.
|
||||
fn forward_facing_frustum() -> Frustum {
|
||||
let proj = glam::camera::rh::proj::vulkan::perspective(60f32.to_radians(), 1.0, 0.1, 100.0);
|
||||
let view = glam::camera::rh::view::look_at_mat4(Vec3::ZERO, Vec3::NEG_Z, Vec3::Y);
|
||||
Frustum::from_view_proj(proj * view)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn box_in_front_is_visible() {
|
||||
let frustum = forward_facing_frustum();
|
||||
assert!(frustum.intersects_aabb(Vec3::new(-1.0, -1.0, -6.0), Vec3::new(1.0, 1.0, -4.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn box_behind_camera_is_culled() {
|
||||
// A box entirely behind the camera. This is the case that fails if the near plane is extracted with the OpenGL `r3 + r2` formula instead of `r2`.
|
||||
let frustum = forward_facing_frustum();
|
||||
assert!(!frustum.intersects_aabb(Vec3::new(-1.0, -1.0, 4.0), Vec3::new(1.0, 1.0, 6.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn box_far_to_the_side_is_culled() {
|
||||
// Well outside the horizontal field of view at an otherwise valid depth.
|
||||
let frustum = forward_facing_frustum();
|
||||
assert!(!frustum.intersects_aabb(Vec3::new(50.0, -1.0, -5.0), Vec3::new(52.0, 1.0, -4.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn huge_box_straddling_origin_is_visible() {
|
||||
let frustum = forward_facing_frustum();
|
||||
assert!(frustum.intersects_aabb(Vec3::splat(-100.0), Vec3::splat(100.0)));
|
||||
}
|
||||
281
crates/renderer/src/tests/meshing.rs
Normal file
281
crates/renderer/src/tests/meshing.rs
Normal file
|
|
@ -0,0 +1,281 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
//! Unit tests for the greedy chunk mesher in [`crate::meshing`].
|
||||
|
||||
use super::*;
|
||||
|
||||
/// Minimal deterministic xorshift64 generator for seeded test chunks.
|
||||
struct Rng(u64);
|
||||
|
||||
impl Rng {
|
||||
fn next(&mut self) -> u64 {
|
||||
let mut x = self.0;
|
||||
x ^= x << 13;
|
||||
x ^= x >> 7;
|
||||
x ^= x << 17;
|
||||
self.0 = x;
|
||||
x
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds a deterministic pseudo-random chunk at roughly one-third density.
|
||||
fn random_chunk(seed: u64) -> Chunk {
|
||||
let mut rng = Rng(seed);
|
||||
let mut chunk = Chunk::default();
|
||||
for x in 0..CHUNK_SIZE {
|
||||
for y in 0..CHUNK_SIZE {
|
||||
for z in 0..CHUNK_SIZE {
|
||||
if rng.next().is_multiple_of(3) {
|
||||
chunk.set(x, y, z, BlockId(1));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
chunk
|
||||
}
|
||||
|
||||
/// Builds a fully solid chunk (every voxel `BlockId(1)`).
|
||||
fn solid_chunk() -> Chunk {
|
||||
let mut chunk = Chunk::default();
|
||||
for x in 0..CHUNK_SIZE {
|
||||
for y in 0..CHUNK_SIZE {
|
||||
for z in 0..CHUNK_SIZE {
|
||||
chunk.set(x, y, z, BlockId(1));
|
||||
}
|
||||
}
|
||||
}
|
||||
chunk
|
||||
}
|
||||
|
||||
/// Counts exposed unit faces the naive way (out-of-chunk neighbours are air).
|
||||
///
|
||||
/// Every unit face has area 1, so this count equals the total surface area a correct greedy mesh must reproduce.
|
||||
fn count_exposed_faces(chunk: &Chunk) -> usize {
|
||||
let mut n = 0;
|
||||
for x in 0..CHUNK_SIZE {
|
||||
for y in 0..CHUNK_SIZE {
|
||||
for z in 0..CHUNK_SIZE {
|
||||
if chunk.get(x, y, z) == BlockId::AIR {
|
||||
continue;
|
||||
}
|
||||
n += usize::from(y == CHUNK_SIZE - 1 || chunk.get(x, y + 1, z) == BlockId::AIR);
|
||||
n += usize::from(y == 0 || chunk.get(x, y - 1, z) == BlockId::AIR);
|
||||
n += usize::from(x == CHUNK_SIZE - 1 || chunk.get(x + 1, y, z) == BlockId::AIR);
|
||||
n += usize::from(x == 0 || chunk.get(x - 1, y, z) == BlockId::AIR);
|
||||
n += usize::from(z == CHUNK_SIZE - 1 || chunk.get(x, y, z + 1) == BlockId::AIR);
|
||||
n += usize::from(z == 0 || chunk.get(x, y, z - 1) == BlockId::AIR);
|
||||
}
|
||||
}
|
||||
}
|
||||
n
|
||||
}
|
||||
|
||||
/// Sums the area of every triangle in the mesh via the cross-product magnitude.
|
||||
fn total_area(vertices: &[Vertex], indices: &[u32]) -> f64 {
|
||||
let mut area = 0.0f64;
|
||||
for tri in indices.chunks_exact(3) {
|
||||
let a = vertices[tri[0] as usize].position;
|
||||
let b = vertices[tri[1] as usize].position;
|
||||
let c = vertices[tri[2] as usize].position;
|
||||
let ab = [
|
||||
f64::from(b[0] - a[0]),
|
||||
f64::from(b[1] - a[1]),
|
||||
f64::from(b[2] - a[2]),
|
||||
];
|
||||
let ac = [
|
||||
f64::from(c[0] - a[0]),
|
||||
f64::from(c[1] - a[1]),
|
||||
f64::from(c[2] - a[2]),
|
||||
];
|
||||
let cross = [
|
||||
ab[1] * ac[2] - ab[2] * ac[1],
|
||||
ab[2] * ac[0] - ab[0] * ac[2],
|
||||
ab[0] * ac[1] - ab[1] * ac[0],
|
||||
];
|
||||
area += 0.5 * cross.iter().map(|c| c * c).sum::<f64>().sqrt();
|
||||
}
|
||||
area
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_air_chunk_is_empty() {
|
||||
let (vertices, indices) = generate_mesh(&Chunk::default(), &Neighbors::default());
|
||||
assert!(vertices.is_empty());
|
||||
assert!(indices.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_block_emits_six_quads() {
|
||||
let mut chunk = Chunk::default();
|
||||
chunk.set(5, 5, 5, BlockId(1));
|
||||
let (vertices, indices) = generate_mesh(&chunk, &Neighbors::default());
|
||||
// Six exposed faces, none mergeable: 6 quads.
|
||||
assert_eq!(vertices.len(), 24);
|
||||
assert_eq!(indices.len(), 36);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn face_direction_indices_match_the_shader_normal_table() {
|
||||
// Pins the numeric contract with the FACE_NORMALS table in assets/shaders/cube.vert, which is indexed by these values. Nothing else connects the two, and a silent reordering would mis-light every face rather than fail to build.
|
||||
assert_eq!(FaceDir::PosX.to_index(), 0);
|
||||
assert_eq!(FaceDir::NegX.to_index(), 1);
|
||||
assert_eq!(FaceDir::PosY.to_index(), 2);
|
||||
assert_eq!(FaceDir::NegY.to_index(), 3);
|
||||
assert_eq!(FaceDir::PosZ.to_index(), 4);
|
||||
assert_eq!(FaceDir::NegZ.to_index(), 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_block_quads_carry_their_own_face_normal() {
|
||||
let mut chunk = Chunk::default();
|
||||
chunk.set(5, 5, 5, BlockId(1));
|
||||
let (vertices, _) = generate_mesh(&chunk, &Neighbors::default());
|
||||
|
||||
// The constant axis and plane coordinate of each face of a block at (5, 5, 5), indexed by packed normal. Deducing the expected direction from the geometry rather than from the emission order keeps the assertion valid if the passes are reordered.
|
||||
let expected: [(usize, f32); 6] = [(0, 6.0), (0, 5.0), (1, 6.0), (1, 5.0), (2, 6.0), (2, 5.0)];
|
||||
|
||||
let mut seen = [false; 6];
|
||||
for quad in vertices.chunks_exact(4) {
|
||||
let normal = quad[0].normal;
|
||||
assert!(
|
||||
quad.iter().all(|v| v.normal == normal),
|
||||
"a planar quad carries more than one normal index"
|
||||
);
|
||||
|
||||
let (axis, plane) = expected[normal as usize];
|
||||
assert!(
|
||||
quad.iter()
|
||||
.all(|v| (v.position[axis] - plane).abs() < f32::EPSILON),
|
||||
"the quad tagged with normal index {normal} does not lie on that face's plane"
|
||||
);
|
||||
|
||||
seen[normal as usize] = true;
|
||||
}
|
||||
|
||||
assert!(
|
||||
seen.iter().all(|&s| s),
|
||||
"an isolated block must emit one quad per face direction"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_chunk_merges_each_face_into_one_quad() {
|
||||
let mut chunk = Chunk::default();
|
||||
for x in 0..CHUNK_SIZE {
|
||||
for y in 0..CHUNK_SIZE {
|
||||
for z in 0..CHUNK_SIZE {
|
||||
chunk.set(x, y, z, BlockId(1));
|
||||
}
|
||||
}
|
||||
}
|
||||
let (vertices, indices) = generate_mesh(&chunk, &Neighbors::default());
|
||||
// Only the six boundary planes are exposed, each merging to a single quad.
|
||||
assert_eq!(vertices.len(), 24);
|
||||
assert_eq!(indices.len(), 36);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adjacent_pair_culls_shared_face_and_merges_sides() {
|
||||
let mut chunk = Chunk::default();
|
||||
chunk.set(0, 0, 0, BlockId(1));
|
||||
chunk.set(1, 0, 0, BlockId(1));
|
||||
let (vertices, indices) = generate_mesh(&chunk, &Neighbors::default());
|
||||
// Shared internal face pair is culled; +Y/-Y/+Z/-Z each merge across the pair into one quad, and the two X ends are one quad each: 6 quads total.
|
||||
assert_eq!(vertices.len(), 24);
|
||||
assert_eq!(indices.len(), 36);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[expect(
|
||||
clippy::cast_precision_loss,
|
||||
reason = "exposed-face counts are far below f64's exact-integer range"
|
||||
)]
|
||||
fn greedy_area_equals_naive_and_never_more_indices() {
|
||||
for seed in 1..=8u64 {
|
||||
let chunk = random_chunk(seed);
|
||||
let (vertices, indices) = generate_mesh(&chunk, &Neighbors::default());
|
||||
let naive_faces = count_exposed_faces(&chunk);
|
||||
|
||||
// Area equality proves no faces were lost, doubled, or misplaced.
|
||||
let expected_area = naive_faces as f64;
|
||||
assert!(
|
||||
(total_area(&vertices, &indices) - expected_area).abs() < 1e-6,
|
||||
"seed {seed}: greedy area diverged from naive"
|
||||
);
|
||||
|
||||
// Merging can only reduce (or match) the index count of the naive mesh.
|
||||
assert!(
|
||||
indices.len() <= naive_faces * 6,
|
||||
"seed {seed}: greedy emitted more indices than naive"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn absent_neighbor_emits_boundary_faces() {
|
||||
// A solid chunk with no neighbours (frontier) still emits all six boundary sheets: None ⇒ air ⇒ emit.
|
||||
let (vertices, _) = generate_mesh(&solid_chunk(), &Neighbors::default());
|
||||
assert_eq!(vertices.len(), 6 * 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn solid_neighbor_culls_that_boundary() {
|
||||
// A solid neighbour on +X occludes the whole +X sheet; the other five boundary planes each still merge to one quad.
|
||||
let neighbor = solid_chunk();
|
||||
let neighbors = Neighbors {
|
||||
pos_x: Some(&neighbor),
|
||||
..Default::default()
|
||||
};
|
||||
let (vertices, indices) = generate_mesh(&solid_chunk(), &neighbors);
|
||||
assert_eq!(vertices.len(), 5 * 4);
|
||||
assert_eq!(indices.len(), 5 * 6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adjacent_solid_chunks_cull_shared_boundary() {
|
||||
// Two solid chunks touching along X: the left chunk's +X sheet and the right chunk's -X sheet are both culled.
|
||||
let left = solid_chunk();
|
||||
let right = solid_chunk();
|
||||
let (left_verts, _) = generate_mesh(
|
||||
&left,
|
||||
&Neighbors {
|
||||
pos_x: Some(&right),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
let (right_verts, _) = generate_mesh(
|
||||
&right,
|
||||
&Neighbors {
|
||||
neg_x: Some(&left),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
assert_eq!(left_verts.len(), 5 * 4);
|
||||
assert_eq!(right_verts.len(), 5 * 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fully_enclosed_solid_chunk_is_empty() {
|
||||
// A solid chunk surrounded on all six sides by solid neighbours exposes no faces at all.
|
||||
let neighbor = solid_chunk();
|
||||
let neighbors = Neighbors {
|
||||
neg_x: Some(&neighbor),
|
||||
pos_x: Some(&neighbor),
|
||||
neg_y: Some(&neighbor),
|
||||
pos_y: Some(&neighbor),
|
||||
neg_z: Some(&neighbor),
|
||||
pos_z: Some(&neighbor),
|
||||
};
|
||||
let (vertices, indices) = generate_mesh(&solid_chunk(), &neighbors);
|
||||
assert!(vertices.is_empty());
|
||||
assert!(indices.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mesh_is_deterministic() {
|
||||
let chunk = random_chunk(42);
|
||||
assert_eq!(
|
||||
generate_mesh(&chunk, &Neighbors::default()),
|
||||
generate_mesh(&chunk, &Neighbors::default())
|
||||
);
|
||||
}
|
||||
37
crates/renderer/src/tests/renderer.rs
Normal file
37
crates/renderer/src/tests/renderer.rs
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
//! Unit tests for the pure helpers in [`crate::renderer`].
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn far_plane_floors_at_the_minimum_for_near_fog() {
|
||||
// Fog that saturates well inside the minimum leaves the far plane at the floor; shrinking it to match would clip geometry for no gain.
|
||||
assert!((far_plane_for(128.0, 64.0) - MIN_FAR_PLANE).abs() < f32::EPSILON);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn far_plane_covers_the_diagonal_of_the_two_extents() {
|
||||
// 768 horizontal and 384 vertical (a radius-24 cylinder) reach 858.6 at the corner, past the 500-block floor.
|
||||
let far = far_plane_for(768.0, 384.0);
|
||||
assert!(far > MIN_FAR_PLANE);
|
||||
assert!(
|
||||
(far - 858.65_f32).abs() < 0.01,
|
||||
"unexpected far plane {far}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn far_plane_reaches_past_each_extent_taken_alone() {
|
||||
// The corner of the box is further than either edge, so covering only the larger extent would still clip partially-visible fragments near the diagonal.
|
||||
let (horizontal, vertical) = (768.0_f32, 384.0_f32);
|
||||
let far = far_plane_for(horizontal, vertical);
|
||||
assert!(far > horizontal);
|
||||
assert!(far > vertical);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn far_plane_is_well_formed_without_fog() {
|
||||
// A caller that supplies no fog distance must still receive a usable projection rather than a degenerate zero-depth one.
|
||||
assert!(far_plane_for(0.0, 0.0) > 0.0);
|
||||
}
|
||||
80
crates/renderer/src/tests/stats.rs
Normal file
80
crates/renderer/src/tests/stats.rs
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
//! Unit tests for the derived arithmetic in [`crate::stats`].
|
||||
|
||||
use super::*;
|
||||
|
||||
/// Builds a stats snapshot whose only meaningful fields are the two mesh counts the ratio is derived from.
|
||||
fn stats_with_counts(visible: usize, culled: usize) -> RenderStats {
|
||||
RenderStats {
|
||||
uploaded_meshes: visible + culled,
|
||||
visible_meshes: visible,
|
||||
culled_meshes: culled,
|
||||
draw_calls: 0,
|
||||
triangles: 0,
|
||||
vertices: 0,
|
||||
vertex_bytes: 0,
|
||||
index_bytes: 0,
|
||||
render_mode: RenderMode::Filled,
|
||||
projection: ProjectionInfo {
|
||||
fov_y_radians: 0.0,
|
||||
aspect: 1.0,
|
||||
near: 0.1,
|
||||
far: 500.0,
|
||||
},
|
||||
swapchain: SwapchainInfo {
|
||||
image_count: 3,
|
||||
width: 1,
|
||||
height: 1,
|
||||
present_mode: "FIFO",
|
||||
},
|
||||
frames_presented: 0,
|
||||
frames_skipped: 0,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cull_ratio_is_zero_when_nothing_is_uploaded() {
|
||||
assert!((stats_with_counts(0, 0).cull_ratio_percent() - 0.0).abs() < f32::EPSILON);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cull_ratio_is_zero_when_every_mesh_is_visible() {
|
||||
assert!((stats_with_counts(8, 0).cull_ratio_percent() - 0.0).abs() < f32::EPSILON);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cull_ratio_is_full_when_every_mesh_is_culled() {
|
||||
assert!((stats_with_counts(0, 8).cull_ratio_percent() - 100.0).abs() < f32::EPSILON);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cull_ratio_is_the_culled_share_of_the_considered_set() {
|
||||
assert!((stats_with_counts(3, 1).cull_ratio_percent() - 25.0).abs() < f32::EPSILON);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn driver_version_uses_the_vulkan_convention_for_unknown_vendors() {
|
||||
// 1.2.131 packed as 22/12/0-bit major/minor/patch.
|
||||
let packed = (1 << 22) | (2 << 12) | 0x83;
|
||||
assert_eq!(decode_driver_version(0x1002, packed), "1.2.131");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn driver_version_uses_the_four_component_layout_for_nvidia() {
|
||||
// 535.104.5.0 packed as 10/8/8/6-bit components.
|
||||
let packed = (535 << 22) | (104 << 14) | (5 << 6);
|
||||
assert_eq!(decode_driver_version(VENDOR_NVIDIA, packed), "535.104.5.0");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn driver_version_for_intel_follows_the_host_platform_convention() {
|
||||
// 101.4502 packed as a 14/18-bit split, which is the Windows layout; the same word decodes differently under the Vulkan convention Mesa follows on Linux.
|
||||
let packed = (101 << 14) | 0x1196;
|
||||
let expected = if cfg!(windows) {
|
||||
"101.4502"
|
||||
} else {
|
||||
"0.405.406"
|
||||
};
|
||||
assert_eq!(decode_driver_version(VENDOR_INTEL, packed), expected);
|
||||
}
|
||||
68
crates/renderer/src/vertex.rs
Normal file
68
crates/renderer/src/vertex.rs
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
//! Vertex data structures and layout descriptions.
|
||||
|
||||
use bytemuck::{Pod, Zeroable};
|
||||
|
||||
/// Represents a single vertex in 3D space with position, colour, and face orientation.
|
||||
///
|
||||
/// Uses `repr(C)` to ensure the memory layout matches what the GPU expects (no Rust-specific reordering).
|
||||
/// `Pod` and `Zeroable` allows safely casting this struct to a raw byte slice. Every field is 4-byte aligned and the struct is 28 bytes, so no implicit padding exists for `Pod` to expose.
|
||||
#[repr(C)]
|
||||
#[derive(Copy, Clone, Debug, PartialEq, Pod, Zeroable)]
|
||||
pub struct Vertex {
|
||||
/// 3D position of the vertex (X, Y, Z).
|
||||
pub position: [f32; 3],
|
||||
/// The RGB color of the vertex [r, g, b].
|
||||
pub color: [f32; 3],
|
||||
/// Index of the face's outward normal into the shader's normal table.
|
||||
///
|
||||
/// Cubic geometry admits only six distinct normals, so the direction is packed as an index rather than a `vec3`, saving 8 bytes per vertex. The vertex shader decodes it; the index ordering is defined by `FaceDir::to_index` in `meshing.rs` and must stay in step with the `FACE_NORMALS` table in `cube.vert`.
|
||||
pub normal: u32,
|
||||
}
|
||||
|
||||
impl Vertex {
|
||||
/// Describes how Vulkan should read the vertex data from a buffer.
|
||||
///
|
||||
/// This defines the 'stride' (distance between vertices) and specifies that data is read per-vertex rather than per-instance.
|
||||
///
|
||||
/// # Panics
|
||||
/// Panics if the size of the vertex structure exceeds the maximum value of a 32-bit unsigned integer.
|
||||
#[expect(
|
||||
clippy::expect_used,
|
||||
reason = "the vertex struct size is far below u32::MAX"
|
||||
)]
|
||||
pub fn get_binding_description() -> ash::vk::VertexInputBindingDescription {
|
||||
ash::vk::VertexInputBindingDescription::default()
|
||||
.binding(0)
|
||||
.stride(
|
||||
u32::try_from(std::mem::size_of::<Self>()).expect("Vertex size exceeds u32 range"),
|
||||
)
|
||||
.input_rate(ash::vk::VertexInputRate::VERTEX)
|
||||
}
|
||||
|
||||
/// Describes the layout of individual fields (attributes) within a single vertex.
|
||||
///
|
||||
/// These 'locations' must match the `layout(location = X)` qualifiers in the vertex shader.
|
||||
pub fn get_attribute_descriptions() -> [ash::vk::VertexInputAttributeDescription; 3] {
|
||||
[
|
||||
// Location 0: position (vec3 -> R32G32B32_SFLOAT)
|
||||
ash::vk::VertexInputAttributeDescription::default()
|
||||
.binding(0)
|
||||
.location(0)
|
||||
.format(ash::vk::Format::R32G32B32_SFLOAT)
|
||||
.offset(0),
|
||||
ash::vk::VertexInputAttributeDescription::default()
|
||||
.binding(0)
|
||||
.location(1)
|
||||
.format(ash::vk::Format::R32G32B32_SFLOAT)
|
||||
.offset(12),
|
||||
// Location 2: packed face normal index (uint -> R32_UINT). The shader input must be declared `uint`; reading an integer-formatted attribute through a float declaration is undefined and silently produces garbage on some drivers.
|
||||
ash::vk::VertexInputAttributeDescription::default()
|
||||
.binding(0)
|
||||
.location(2)
|
||||
.format(ash::vk::Format::R32_UINT)
|
||||
.offset(24),
|
||||
]
|
||||
}
|
||||
}
|
||||
11
crates/scripting/Cargo.toml
Normal file
11
crates/scripting/Cargo.toml
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
[package]
|
||||
name = "scripting"
|
||||
license.workspace = true
|
||||
authors.workspace = true
|
||||
edition.workspace = true
|
||||
version.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
17
crates/scripting/src/lib.rs
Normal file
17
crates/scripting/src/lib.rs
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
//! Lua scripting and modding support for Synvael.
|
||||
//!
|
||||
//! This crate handles the integration with Lua (via `mlua`) and provides
|
||||
//! the API surface for both base game content and third-party mods.
|
||||
|
||||
/// Adds two numbers together.
|
||||
///
|
||||
/// This is a placeholder function for the initial crate setup.
|
||||
pub fn add(left: u64, right: u64) -> u64 {
|
||||
left + right
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tests/lib.rs"]
|
||||
mod tests;
|
||||
9
crates/scripting/src/tests/lib.rs
Normal file
9
crates/scripting/src/tests/lib.rs
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn it_works() {
|
||||
let result = add(2, 2);
|
||||
assert_eq!(result, 4);
|
||||
}
|
||||
24
crates/server/Cargo.toml
Normal file
24
crates/server/Cargo.toml
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
[package]
|
||||
name = "server"
|
||||
license.workspace = true
|
||||
authors.workspace = true
|
||||
edition.workspace = true
|
||||
version.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
bevy_ecs = "0.19"
|
||||
crossbeam-channel = "0.5.16"
|
||||
glam.workspace = true
|
||||
lru = "0.18.1"
|
||||
net = { version = "0.1.0", path = "../net" }
|
||||
serde_json.workspace = true
|
||||
shared = { path = "../shared" }
|
||||
tracing.workspace = true
|
||||
tracing-subscriber.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3.27.0"
|
||||
78
crates/server/src/chunk_cache.rs
Normal file
78
crates/server/src/chunk_cache.rs
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
//! A bounded cache of regenerated chunk baselines, shared across the worker pool.
|
||||
|
||||
use std::num::NonZeroUsize;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use lru::LruCache;
|
||||
use shared::generator::VoxelGenerator;
|
||||
use shared::world::{Chunk, ChunkPos};
|
||||
|
||||
/// A least-recently-used cache of chunk baselines, cloneable so every worker shares one store.
|
||||
#[derive(Clone)]
|
||||
pub struct ChunkCache {
|
||||
/// The shared LRU store.
|
||||
inner: Arc<Mutex<LruCache<ChunkPos, Chunk>>>,
|
||||
}
|
||||
|
||||
impl ChunkCache {
|
||||
/// Creates an empty cache holding at most `capacity` baselines before evicting the least-recently-used entry.
|
||||
#[must_use]
|
||||
pub fn new(capacity: NonZeroUsize) -> Self {
|
||||
Self {
|
||||
inner: Arc::new(Mutex::new(LruCache::new(capacity))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the baseline for `pos`, generating and caching it on a miss.
|
||||
///
|
||||
/// Generation runs outside the lock, so concurrent callers do not serialise on a cache miss. Two callers racing on the same position may each generate a baseline; generation is deterministic and side-effect-free, so the duplicated work is redundant rather than incorrect, and is far cheaper than serialising every miss behind the store.
|
||||
#[must_use]
|
||||
pub fn get_or_generate(&self, pos: ChunkPos, generator: &VoxelGenerator) -> Chunk {
|
||||
// Probe under the lock, then release it before generating.
|
||||
{
|
||||
// A poisoned lock cannot yield a usable cache; recovering the guard lets generation proceed rather than propagating a panic across every worker that shares this cache.
|
||||
let mut guard = self
|
||||
.inner
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
if let Some(hit) = guard.get(&pos) {
|
||||
return hit.clone();
|
||||
}
|
||||
}
|
||||
|
||||
let chunk = generator.generate_chunk(pos);
|
||||
|
||||
// Retake the lock only to publish. A concurrent caller may have inserted the same position in the meantime; overwriting is harmless because both baselines are identical.
|
||||
self.inner
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
.put(pos, chunk.clone());
|
||||
|
||||
chunk
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
impl ChunkCache {
|
||||
/// Number of baselines currently resident. Test-only introspection.
|
||||
fn len(&self) -> usize {
|
||||
self.inner
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
.len()
|
||||
}
|
||||
|
||||
/// Whether a baseline for `pos` is currently resident. Test-only introspection.
|
||||
fn contains(&self, pos: ChunkPos) -> bool {
|
||||
self.inner
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
.contains(&pos)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tests/chunk_cache.rs"]
|
||||
mod tests;
|
||||
140
crates/server/src/client_stream.rs
Normal file
140
crates/server/src/client_stream.rs
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
//! Per-connection outbound streaming state: chunk subscription tracking and the authority-stream sink.
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
use net::{AuthoritySink, ChunkSink};
|
||||
use shared::protocol::authority::{AuthorityMessage, ServerStats};
|
||||
use shared::protocol::chunk::ChunkMessage;
|
||||
use shared::world::{Chunk, ChunkData, ChunkPos};
|
||||
|
||||
use crate::world_server::{ServerWorld, cylinder_chunks};
|
||||
|
||||
/// Upper bound, in chunks, on a client's requested load radius. A larger request is clamped to this, bounding the per-client resident set and the reconcile cost the server performs on the client's behalf.
|
||||
// TODO: derive from server configuration and per-tier LOD limits.
|
||||
pub const SERVER_MAX_RADIUS: u16 = 24;
|
||||
|
||||
/// Worldgen version stamped on delivered chunk diffs. A single version exists today; this becomes the chunk's stored version once worldgen versioning lands.
|
||||
const WORLDGEN_VERSION: u32 = 0;
|
||||
|
||||
/// Upper bound on chunks encoded and sent to one client per tick.
|
||||
// TODO: replace the fixed count with a time budget once per-chunk cost varies with LOD.
|
||||
const MAX_DELIVERIES_PER_TICK: usize = 32;
|
||||
|
||||
/// The load and drop lists produced by diffing a client's previous desired set against a new one.
|
||||
#[derive(Debug, Default, PartialEq, Eq)]
|
||||
pub struct DesiredDiff {
|
||||
/// Positions newly wanted (present in the new set, absent from the previous). Delivered once resident.
|
||||
pub added: Vec<ChunkPos>,
|
||||
/// Positions no longer wanted (present in the previous set, absent from the new). The client is told to drop each it holds.
|
||||
pub removed: Vec<ChunkPos>,
|
||||
}
|
||||
|
||||
/// Computes the load/drop diff between a client's `previous` and `new` desired sets.
|
||||
#[must_use]
|
||||
pub fn desired_diff<S: std::hash::BuildHasher>(
|
||||
previous: &HashSet<ChunkPos, S>,
|
||||
new: &HashSet<ChunkPos, S>,
|
||||
) -> DesiredDiff {
|
||||
let mut added: Vec<ChunkPos> = new.difference(previous).copied().collect();
|
||||
let mut removed: Vec<ChunkPos> = previous.difference(new).copied().collect();
|
||||
added.sort_unstable();
|
||||
removed.sort_unstable();
|
||||
DesiredDiff { added, removed }
|
||||
}
|
||||
|
||||
/// Tracks one connected client's chunk subscription, what has been delivered to it, and the sinks used to push to it.
|
||||
pub struct ClientStream {
|
||||
/// Outbound handle onto the client's chunk stream.
|
||||
sink: ChunkSink,
|
||||
/// Outbound handle onto the client's authority stream, carried here so both per-connection sinks share one lifetime and one lookup key.
|
||||
authority: AuthoritySink,
|
||||
/// The chunk positions the client currently wants resident, already clamped to [`SERVER_MAX_RADIUS`].
|
||||
desired: HashSet<ChunkPos>,
|
||||
/// Positions already delivered to the client as [`ChunkMessage::Chunk`].
|
||||
sent: HashSet<ChunkPos>,
|
||||
}
|
||||
|
||||
impl ClientStream {
|
||||
/// Creates a stream for a freshly connected client that has not yet subscribed.
|
||||
#[must_use]
|
||||
pub fn new(sink: ChunkSink, authority: AuthoritySink) -> Self {
|
||||
Self {
|
||||
sink,
|
||||
authority,
|
||||
desired: HashSet::new(),
|
||||
sent: HashSet::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the client's current desired set, for folding into the world reconcile union.
|
||||
#[must_use]
|
||||
pub fn desired(&self) -> &HashSet<ChunkPos> {
|
||||
&self.desired
|
||||
}
|
||||
|
||||
/// Applies a new subscription centered on `center` with load radius `radius`.
|
||||
///
|
||||
/// The radius is clamped to [`SERVER_MAX_RADIUS`], the desired set is recomputed, and a [`ChunkMessage::Drop`] is emitted for every already-delivered chunk that left the set. Chunks newly entering the set are not sent here; they are delivered by [`ClientStream::flush`] once resident. Returns the number of newly-wanted positions and the number of drops emitted.
|
||||
pub fn resubscribe(&mut self, center: ChunkPos, radius: u16) -> (usize, usize) {
|
||||
let clamped = radius.min(SERVER_MAX_RADIUS);
|
||||
let mut new_desired = HashSet::new();
|
||||
cylinder_chunks(center, i32::from(clamped), &mut new_desired);
|
||||
|
||||
let diff = desired_diff(&self.desired, &new_desired);
|
||||
let added = diff.added.len();
|
||||
let mut drops = 0;
|
||||
for pos in diff.removed {
|
||||
// Only chunks actually delivered need an explicit drop; positions that were wanted but never resident were never held by the client.
|
||||
if self.sent.remove(&pos) {
|
||||
self.sink.send(ChunkMessage::Drop { pos });
|
||||
drops += 1;
|
||||
}
|
||||
}
|
||||
self.desired = new_desired;
|
||||
(added, drops)
|
||||
}
|
||||
|
||||
/// Delivers every desired-but-undelivered chunk that has become resident in `world`.
|
||||
///
|
||||
/// Each chunk is encoded as a [`ChunkData`] diff against `baseline` (an all-air chunk), making the payload self-contained. Positions still pending in the worker pool are skipped and retried on a later call, as are positions beyond [`MAX_DELIVERIES_PER_TICK`]. Returns the number of chunks delivered.
|
||||
pub fn flush(&mut self, world: &ServerWorld, baseline: &Chunk) -> usize {
|
||||
// Collected first to avoid borrowing `self.desired` while mutating `self.sent`.
|
||||
let ready: Vec<ChunkPos> = self
|
||||
.desired
|
||||
.iter()
|
||||
.filter(|pos| !self.sent.contains(pos))
|
||||
.copied()
|
||||
.collect();
|
||||
|
||||
let mut delivered = 0;
|
||||
for pos in ready {
|
||||
// The budget counts chunks actually encoded, so ticks where most of the desired set is still in flight are not charged for work they did not do.
|
||||
if delivered >= MAX_DELIVERIES_PER_TICK {
|
||||
break;
|
||||
}
|
||||
|
||||
let Some(chunk) = world.chunk(pos) else {
|
||||
// Not resident yet; a later flush retries once the worker pool returns it.
|
||||
continue;
|
||||
};
|
||||
let data = ChunkData::from_diff(pos, WORLDGEN_VERSION, baseline, chunk);
|
||||
self.sink.send(ChunkMessage::Chunk { pos, data });
|
||||
self.sent.insert(pos);
|
||||
delivered += 1;
|
||||
}
|
||||
delivered
|
||||
}
|
||||
|
||||
/// Pushes a diagnostics snapshot onto the client's authority stream.
|
||||
///
|
||||
/// Non-blocking, and silently ignored when the connection has already gone away; see [`AuthoritySink::send`].
|
||||
pub fn send_stats(&self, stats: ServerStats) {
|
||||
self.authority.send(AuthorityMessage::ServerStats(stats));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tests/client_stream.rs"]
|
||||
mod tests;
|
||||
285
crates/server/src/main.rs
Normal file
285
crates/server/src/main.rs
Normal file
|
|
@ -0,0 +1,285 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
//! Dedicated server for Synvael.
|
||||
//!
|
||||
//! The server handles the authoritative game simulation, including world management, physics, and combat.
|
||||
|
||||
/// A bounded LRU cache of regenerated chunk baselines, shared across the worker pool.
|
||||
pub mod chunk_cache;
|
||||
/// Per-connection chunk-streaming state: desired-set tracking and delivery.
|
||||
pub mod client_stream;
|
||||
/// Entity components describing players and other world-streaming anchors.
|
||||
pub mod player;
|
||||
/// On-disk persistence: region files and the atomic durability layer.
|
||||
pub mod save;
|
||||
/// Formatting of the periodic simulation statistics report.
|
||||
pub mod stats;
|
||||
/// Measurement of the simulation loop's achieved tick rate and per-tick cost.
|
||||
pub mod tick_stats;
|
||||
/// Authoritative chunk storage and generation logic for the server.
|
||||
pub mod world_server;
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::fs;
|
||||
use std::net::{Ipv4Addr, SocketAddr};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use anyhow::Context;
|
||||
use bevy_ecs::prelude::{Query, ResMut, Schedule, With, Without, World};
|
||||
use glam::Vec3;
|
||||
use shared::generator::{VoxelGenerator, WorldGenConfig};
|
||||
use shared::protocol::authority::ServerStats;
|
||||
use shared::world::{Chunk, ChunkPos, EntityPos};
|
||||
use tracing::{debug, info, trace, warn};
|
||||
|
||||
use client_stream::ClientStream;
|
||||
use net::{NetworkServer, ServerEvent};
|
||||
use player::{Player, Position, ViewDistance};
|
||||
use tick_stats::{TickMeter, TickWindow};
|
||||
use world_server::{ServerWorld, cylinder_chunks};
|
||||
|
||||
/// Nominal simulation rate, in ticks per second. Sole source of truth for both the loop's target period and the advisory rate advertised to clients in the handshake.
|
||||
// TODO: make configurable through server configs once the real tick scheduler lands.
|
||||
const TICK_RATE_HZ: u16 = 20;
|
||||
|
||||
/// Target wall-clock period of one simulation tick, derived from [`TICK_RATE_HZ`].
|
||||
const TICK_PERIOD: Duration = Duration::from_millis(1000 / TICK_RATE_HZ as u64);
|
||||
|
||||
/// Wall-clock period between statistics panels written to the log.
|
||||
// TODO: make configurable through server configs, alongside the tick rate.
|
||||
const STATUS_INTERVAL: Duration = Duration::from_mins(1);
|
||||
|
||||
/// Streaming system: loads and unloads chunks so that the resident set matches the union of the cylinders around every player anchor.
|
||||
fn stream_chunks(
|
||||
anchors: Query<(&Position, &ViewDistance), With<Player>>,
|
||||
mut world: ResMut<ServerWorld>,
|
||||
) {
|
||||
// Desired set is the union of every anchor's cylinder; a chunk survives as long as it lies within any one player's view.
|
||||
let mut desired = HashSet::new();
|
||||
for (position, view) in &anchors {
|
||||
cylinder_chunks(position.0.chunk, view.0, &mut desired);
|
||||
}
|
||||
|
||||
world.reconcile(&desired);
|
||||
}
|
||||
|
||||
fn main() -> anyhow::Result<()> {
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(
|
||||
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("debug")),
|
||||
)
|
||||
.init();
|
||||
|
||||
info!("Starting Synvael server");
|
||||
|
||||
let config_str = fs::read_to_string("assets/data/worldgen/default.json")
|
||||
.context("reading worldgen config assets/data/worldgen/default.json")?;
|
||||
|
||||
let worldgen_config: WorldGenConfig =
|
||||
serde_json::from_str(&config_str).context("parsing worldgen config as JSON")?;
|
||||
|
||||
info!("Successfully loaded world configuration");
|
||||
debug!("Base height: {}", worldgen_config.base_height);
|
||||
debug!("Noise scale: {}", worldgen_config.noise_scale);
|
||||
debug!("Surface block: {}", worldgen_config.surface_block.0);
|
||||
debug!("Subsurface block: {}", worldgen_config.subsurface_block.0);
|
||||
debug!("Stone block: {}", worldgen_config.stone_block.0);
|
||||
|
||||
let seed = 4_813_530;
|
||||
|
||||
let generator = VoxelGenerator::new(worldgen_config, seed);
|
||||
|
||||
// The region directory holds the `.region` save files for this world
|
||||
// TODO: resolve it per named world under a shared save root.
|
||||
let region_dir = std::path::PathBuf::from("saves/default/region");
|
||||
|
||||
// Number of chunk baselines the worker pool retains before evicting the least-recently-used entry
|
||||
// TODO: make this configurable through server configs
|
||||
let cache_capacity =
|
||||
std::num::NonZeroUsize::new(4_096).context("chunk cache capacity is non-zero")?;
|
||||
|
||||
let mut world = World::new();
|
||||
world.insert_resource(ServerWorld::new(generator, region_dir, cache_capacity));
|
||||
|
||||
// Spawn a single dummy player anchor at the world origin.
|
||||
world.spawn((
|
||||
Player,
|
||||
Position(EntityPos::new(ChunkPos::new(0, 0, 0), Vec3::ZERO)),
|
||||
ViewDistance(4),
|
||||
));
|
||||
|
||||
// A schedule is one tick's worth of systems; running it advances the world.
|
||||
let mut schedule = Schedule::default();
|
||||
schedule.add_systems(stream_chunks);
|
||||
|
||||
// Loading phase: dispatch the initial region and wait for the worker pool to finish before granting control.
|
||||
info!("Streaming initial region");
|
||||
// The loop below spins as fast as the worker pool is polled, so progress is reported only when the resident count actually advances. Logging every iteration would emit thousands of identical lines before the endpoint is even bound.
|
||||
let mut last_reported = usize::MAX;
|
||||
loop {
|
||||
schedule.run(&mut world);
|
||||
|
||||
let server_world = world.resource::<ServerWorld>();
|
||||
let resident = server_world.loaded_count();
|
||||
let in_flight = server_world.in_flight_count();
|
||||
if resident != last_reported {
|
||||
// Loading progress is simply the resident fraction of all known chunks.
|
||||
let total = resident + in_flight;
|
||||
debug!(resident, in_flight, total, "loading progress");
|
||||
last_reported = resident;
|
||||
}
|
||||
|
||||
// The region is ready once at least one chunk has been generated and none remain in flight.
|
||||
if server_world.streaming_idle() && resident > 0 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
info!("Initial region ready; granting player control");
|
||||
|
||||
// Spawn the networking thread and bind the QUIC endpoint. The synchronous simulation loop below communicates with it only by draining events.
|
||||
let bind = SocketAddr::from((Ipv4Addr::LOCALHOST, net::DEFAULT_PORT));
|
||||
let (network, local_addr) =
|
||||
NetworkServer::spawn(bind, env!("CARGO_PKG_VERSION").to_owned(), TICK_RATE_HZ)
|
||||
.context("spawning network server")?;
|
||||
info!(%local_addr, "network endpoint listening");
|
||||
|
||||
run_simulation(&mut world, &network)
|
||||
}
|
||||
|
||||
/// Assembles the diagnostics snapshot pushed to clients at the end of a measurement window.
|
||||
///
|
||||
/// The world and ECS figures are read at the moment of the call rather than averaged over the window: they describe a level of occupancy, for which the current value is the meaningful reading. Only the timing figures in `window` are aggregates.
|
||||
fn collect_server_stats(
|
||||
world: &mut World,
|
||||
window: &TickWindow,
|
||||
connected_clients: usize,
|
||||
started_at: Instant,
|
||||
) -> ServerStats {
|
||||
let (loaded_chunks, chunks_in_flight) = {
|
||||
let server_world = world.resource::<ServerWorld>();
|
||||
(server_world.loaded_count(), server_world.in_flight_count())
|
||||
};
|
||||
let players = world
|
||||
.query_filtered::<(), With<Player>>()
|
||||
.iter(world)
|
||||
.count();
|
||||
let entities = world
|
||||
.query_filtered::<(), (With<Position>, Without<Player>)>()
|
||||
.iter(world)
|
||||
.count();
|
||||
|
||||
ServerStats {
|
||||
measured_tps: window.measured_tps,
|
||||
mean_tick_ms: window.mean_tick_ms,
|
||||
max_tick_ms: window.max_tick_ms,
|
||||
tick_budget_percent: window.tick_budget_percent,
|
||||
loaded_chunks: u32::try_from(loaded_chunks).unwrap_or(u32::MAX),
|
||||
chunks_in_flight: u32::try_from(chunks_in_flight).unwrap_or(u32::MAX),
|
||||
connected_clients: u32::try_from(connected_clients).unwrap_or(u32::MAX),
|
||||
entities: u32::try_from(entities).unwrap_or(u32::MAX),
|
||||
players: u32::try_from(players).unwrap_or(u32::MAX),
|
||||
uptime_secs: started_at.elapsed().as_secs(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Emits one measurement window's diagnostics to the log.
|
||||
///
|
||||
/// A dedicated server is headless and has no statistics panel to read, so the same snapshot pushed to clients is also reported locally. The cadence is the measurement window rather than the tick, which keeps the line rare enough to leave the log readable while still surfacing a rate collapse within a second.
|
||||
fn report_statistics(stats: &ServerStats) {
|
||||
info!("\n{}", stats::format_panel(stats));
|
||||
}
|
||||
|
||||
/// Runs the authoritative simulation loop forever, at the fixed cadence given by [`TICK_PERIOD`].
|
||||
fn run_simulation(world: &mut World, network: &NetworkServer) -> ! {
|
||||
// Chunk diffs are computed against an all-air baseline so each delivered payload is self-contained: the client renders only server-owned content and has no generator to reconstruct a worldgen baseline. Allocated once and shared across every delivery.
|
||||
let empty_baseline = Chunk::default();
|
||||
|
||||
// Per-connection streaming state, keyed by the stable session id the network thread assigns.
|
||||
let mut clients: HashMap<u64, ClientStream> = HashMap::new();
|
||||
|
||||
let started_at = Instant::now();
|
||||
let mut meter = TickMeter::new(started_at, TICK_PERIOD);
|
||||
// Seeded at startup so the first panel appears one interval in, rather than immediately on a world that has not yet settled.
|
||||
let mut last_status = started_at;
|
||||
|
||||
loop {
|
||||
let tick_start = Instant::now();
|
||||
|
||||
// Fold network events into per-client subscription state.
|
||||
for event in network.poll_events() {
|
||||
match event {
|
||||
ServerEvent::ClientConnected {
|
||||
id,
|
||||
hello,
|
||||
chunks,
|
||||
authority,
|
||||
} => {
|
||||
info!(id, name = %hello.player_identity.display_name, "client connected");
|
||||
clients.insert(id, ClientStream::new(chunks, authority));
|
||||
}
|
||||
ServerEvent::ClientDisconnected { id, reason } => {
|
||||
info!(id, %reason, "client disconnected");
|
||||
clients.remove(&id);
|
||||
}
|
||||
ServerEvent::ChunkSubscribe { id, request } => {
|
||||
if let Some(client) = clients.get_mut(&id) {
|
||||
let (added, drops) = client.resubscribe(request.center, request.radius);
|
||||
// Fires on every chunk boundary the client crosses, so it sits below the connect and disconnect events rather than beside them.
|
||||
debug!(id, added, drops, "client resubscribed");
|
||||
} else {
|
||||
warn!(id, "chunk subscribe from unknown session");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Reconcile the resident world to the union of every client's desired set. A chunk survives as long as any connected client wants it; when no client is connected the union is empty and the world drains.
|
||||
let mut desired = HashSet::new();
|
||||
for client in clients.values() {
|
||||
desired.extend(client.desired().iter().copied());
|
||||
}
|
||||
world.resource_mut::<ServerWorld>().reconcile(&desired);
|
||||
|
||||
// Deliver newly-resident chunks to each client. Loads dispatched above may not be resident this tick; `flush` retries on later ticks until the worker pool returns them.
|
||||
let server_world = world.resource::<ServerWorld>();
|
||||
for (id, client) in &mut clients {
|
||||
let delivered = client.flush(server_world, &empty_baseline);
|
||||
if delivered > 0 {
|
||||
// Per-tick and per-client, so it sits below the default filter: the aggregate chunk figures in the status line cover routine operation, and this level is for tracing an individual client's deliveries.
|
||||
trace!(id, delivered, "delivered resident chunks");
|
||||
}
|
||||
}
|
||||
|
||||
// Sleep only the unused remainder of the tick's budget, so the period stays [`TICK_PERIOD`] rather than growing with the cost of the work above. A tick that overruns its budget does not sleep at all; the overrun is reported because it is the signal that the server is falling behind its nominal rate.
|
||||
let elapsed = tick_start.elapsed();
|
||||
meter.record(elapsed);
|
||||
|
||||
// Diagnostics are pushed on the authority stream once per measurement window, not per tick: the figures describe the window, and per-tick delivery would be pure waste.
|
||||
let window_end = tick_start + elapsed;
|
||||
if let Some(window) = meter.take_window(window_end) {
|
||||
let stats = collect_server_stats(world, &window, clients.len(), started_at);
|
||||
|
||||
// Every window reaches the clients, which display it live; the log takes one panel per [`STATUS_INTERVAL`].
|
||||
if window_end.duration_since(last_status) >= STATUS_INTERVAL {
|
||||
report_statistics(&stats);
|
||||
last_status = window_end;
|
||||
}
|
||||
|
||||
for client in clients.values() {
|
||||
client.send_stats(stats);
|
||||
}
|
||||
}
|
||||
|
||||
if elapsed > TICK_PERIOD {
|
||||
warn!(
|
||||
elapsed_ms = elapsed.as_secs_f32() * 1000.0,
|
||||
budget_ms = TICK_PERIOD.as_secs_f32() * 1000.0,
|
||||
"tick overran its budget"
|
||||
);
|
||||
} else {
|
||||
// `saturating_sub` cannot underflow here (the branch already establishes `elapsed <= TICK_PERIOD`) and is used because `Duration` subtraction panics on overflow.
|
||||
std::thread::sleep(TICK_PERIOD.saturating_sub(elapsed));
|
||||
}
|
||||
}
|
||||
}
|
||||
18
crates/server/src/player.rs
Normal file
18
crates/server/src/player.rs
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
//! Entity components describing players and other world-streaming anchors.
|
||||
|
||||
use bevy_ecs::prelude::Component;
|
||||
use shared::world::EntityPos;
|
||||
|
||||
/// Marker component identifying an entity as a player.
|
||||
#[derive(Component)]
|
||||
pub struct Player;
|
||||
|
||||
/// Authoritative world-space position of an entity.
|
||||
#[derive(Component)]
|
||||
pub struct Position(pub EntityPos);
|
||||
|
||||
/// Chunk-streaming radius, in chunks, of the cylinder loaded around an entity.
|
||||
#[derive(Component)]
|
||||
pub struct ViewDistance(pub i32);
|
||||
12
crates/server/src/save.rs
Normal file
12
crates/server/src/save.rs
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
//! Server-side persistence: the durability layer over the `shared` save format.
|
||||
//!
|
||||
//! `shared::save` owns the pure, in-memory framing (the `SYNR` index and `SYNC` records).
|
||||
//! This module owns the filesystem side: reading a `.region` file into memory, mutating its chunks, and flushing it back to disk crash-safely. The write strategy is a whole-file atomic rewrite (`.tmp` + fsync + rename); the on-disk format is unchanged.
|
||||
|
||||
mod region_actor;
|
||||
mod region_file;
|
||||
|
||||
pub use region_actor::{SaveActor, SaveRequest};
|
||||
pub use region_file::{REGION_SIZE, RegionFile, region_coords, region_path};
|
||||
164
crates/server/src/save/region_actor.rs
Normal file
164
crates/server/src/save/region_actor.rs
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
//! A dedicated thread that owns every open region file and services load, write-back, remove, and flush requests over a channel.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::collections::hash_map::Entry;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::thread::{self, JoinHandle};
|
||||
|
||||
use crossbeam_channel::{Receiver, Sender};
|
||||
use shared::save::SaveError;
|
||||
use shared::world::{ChunkData, ChunkPos};
|
||||
use tracing::warn;
|
||||
|
||||
use super::region_file::{RegionFile, region_coords, region_path};
|
||||
|
||||
/// A request sent to the save actor. Read and flush requests carry a one-shot reply channel; write and remove requests are fire-and-forget, mutating only the in-memory region image until a flush.
|
||||
pub enum SaveRequest {
|
||||
/// Reads the stored chunk at a position, replying with the saved modification if one exists.
|
||||
Read {
|
||||
/// The chunk position to look up.
|
||||
pos: ChunkPos,
|
||||
/// The one-shot channel the actor replies on: `Ok(Some(data))` for a saved modification, `Ok(None)` when the chunk was never modified, or `Err` on a save-layer failure.
|
||||
reply: Sender<Result<Option<ChunkData>, SaveError>>,
|
||||
},
|
||||
/// Writes a modified chunk's diff into its region, replacing any prior record. Mutates only the in-memory image; durability waits for a `Flush`.
|
||||
Write {
|
||||
/// The chunk position the diff is stored under.
|
||||
pos: ChunkPos,
|
||||
/// The baseline-relative diff to persist.
|
||||
data: ChunkData,
|
||||
},
|
||||
/// Drops any stored record for a position, reclaiming its space into the region free list. Used when a clean chunk is unloaded.
|
||||
Remove {
|
||||
/// The chunk position whose record is dropped.
|
||||
pos: ChunkPos,
|
||||
},
|
||||
/// Flushes every dirty region to disk, replying once all are written.
|
||||
Flush {
|
||||
/// The one-shot channel the actor replies on: `Ok(())` when every dirty region flushed, or the first `Err` encountered.
|
||||
reply: Sender<Result<(), SaveError>>,
|
||||
},
|
||||
}
|
||||
|
||||
/// A handle to the running save actor: the request sender plus the owning thread's join handle.
|
||||
pub struct SaveActor {
|
||||
/// The sending end of the request channel; cloned into every worker so it can issue reads.
|
||||
request_tx: Sender<SaveRequest>,
|
||||
/// The actor thread handle, retained so it can be joined on shutdown.
|
||||
#[expect(
|
||||
dead_code,
|
||||
reason = "retained for a future graceful-shutdown join path"
|
||||
)]
|
||||
handle: JoinHandle<()>,
|
||||
}
|
||||
|
||||
impl SaveActor {
|
||||
/// Spawns the actor thread, which owns the region files beneath `region_dir` for its lifetime.
|
||||
#[must_use]
|
||||
pub fn spawn(region_dir: PathBuf) -> Self {
|
||||
let (request_tx, request_rx) = crossbeam_channel::unbounded::<SaveRequest>();
|
||||
let handle = thread::spawn(move || actor_loop(®ion_dir, &request_rx));
|
||||
Self { request_tx, handle }
|
||||
}
|
||||
|
||||
/// Returns a fresh sender for a worker to issue requests through.
|
||||
#[must_use]
|
||||
pub fn sender(&self) -> Sender<SaveRequest> {
|
||||
self.request_tx.clone()
|
||||
}
|
||||
}
|
||||
|
||||
/// The actor's run loop: it owns the region-file map and answers requests until every sender is dropped.
|
||||
fn actor_loop(region_dir: &Path, request_rx: &Receiver<SaveRequest>) {
|
||||
// The actor is the sole owner of this map, so region files need no lock of their own.
|
||||
let mut regions: HashMap<(i32, i32, i32), RegionFile> = HashMap::new();
|
||||
while let Ok(request) = request_rx.recv() {
|
||||
match request {
|
||||
SaveRequest::Read { pos, reply } => {
|
||||
let result = read_chunk(&mut regions, region_dir, pos);
|
||||
// A send error means the requesting worker has gone away; the reply is simply dropped.
|
||||
let _ = reply.send(result);
|
||||
}
|
||||
SaveRequest::Write { pos, data } => {
|
||||
// `last_modified` is record metadata only, not a worldgen input, so a zero placeholder is acceptable until a real timestamp source is wired.
|
||||
let last_modified = 0;
|
||||
match region_mut(&mut regions, region_dir, pos) {
|
||||
Ok(region) => {
|
||||
// A failed encode must not go unnoticed; the write is otherwise silently lost.
|
||||
if let Err(error) = region.write_chunk(pos, &data, last_modified) {
|
||||
warn!(?error, ?pos, "chunk write-back failed; edit dropped");
|
||||
}
|
||||
}
|
||||
Err(error) => warn!(?error, ?pos, "region open failed; write-back dropped"),
|
||||
}
|
||||
}
|
||||
SaveRequest::Remove { pos } => match region_mut(&mut regions, region_dir, pos) {
|
||||
Ok(region) => region.remove_chunk(pos),
|
||||
Err(error) => warn!(?error, ?pos, "region open failed; record not removed"),
|
||||
},
|
||||
SaveRequest::Flush { reply } => {
|
||||
let result = flush_dirty(&mut regions);
|
||||
// A send error means the requester has gone away; the reply is simply dropped.
|
||||
let _ = reply.send(result);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Flushes every dirty region to disk, returning the first error while still attempting the rest.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns the first [`SaveError`] produced by [`RegionFile::save`]; remaining dirty regions are still flushed.
|
||||
fn flush_dirty(regions: &mut HashMap<(i32, i32, i32), RegionFile>) -> Result<(), SaveError> {
|
||||
let mut result = Ok(());
|
||||
for region in regions.values_mut() {
|
||||
// Clean regions are skipped so a flush never rewrites an unchanged file.
|
||||
if !region.is_dirty() {
|
||||
continue;
|
||||
}
|
||||
if let Err(error) = region.save() {
|
||||
warn!(?error, "region flush failed");
|
||||
// The first failure is reported; later regions are still flushed.
|
||||
if result.is_ok() {
|
||||
result = Err(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// Returns the region file covering `pos`, opening and caching it on first access.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns a [`SaveError`] from [`RegionFile::open`] if the region file exists but cannot be read or decoded.
|
||||
fn region_mut<'a>(
|
||||
regions: &'a mut HashMap<(i32, i32, i32), RegionFile>,
|
||||
region_dir: &Path,
|
||||
pos: ChunkPos,
|
||||
) -> Result<&'a mut RegionFile, SaveError> {
|
||||
let key = region_coords(pos.x, pos.y, pos.z);
|
||||
// The region file is opened once on first touch; every later access hits the in-memory copy.
|
||||
match regions.entry(key) {
|
||||
Entry::Occupied(entry) => Ok(entry.into_mut()),
|
||||
Entry::Vacant(entry) => Ok(entry.insert(RegionFile::open(region_path(
|
||||
region_dir, pos.x, pos.y, pos.z,
|
||||
))?)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads the stored chunk at `pos`, opening and caching its region file on first access.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns a [`SaveError`] if the region file cannot be opened or the stored record cannot be decoded.
|
||||
fn read_chunk(
|
||||
regions: &mut HashMap<(i32, i32, i32), RegionFile>,
|
||||
region_dir: &Path,
|
||||
pos: ChunkPos,
|
||||
) -> Result<Option<ChunkData>, SaveError> {
|
||||
region_mut(regions, region_dir, pos)?.read_chunk(pos)
|
||||
}
|
||||
251
crates/server/src/save/region_file.rs
Normal file
251
crates/server/src/save/region_file.rs
Normal file
|
|
@ -0,0 +1,251 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
//! The durability layer for a single region file.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::ffi::OsString;
|
||||
use std::fs::{self, File};
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use shared::save::SaveError;
|
||||
use shared::save::record;
|
||||
use shared::save::region::{HeaderEntry, RegionIndex};
|
||||
use shared::world::{ChunkData, ChunkPos};
|
||||
|
||||
/// The side length, in chunks, of the cube one region file covers on every axis.
|
||||
pub const REGION_SIZE: i32 = 32;
|
||||
|
||||
/// Maps a chunk `(cx, cy, cz)` to the coordinates `(rx, ry, rz)` of the region cube that contains it.
|
||||
///
|
||||
/// Every axis is floored via `div_euclid` (not truncating division) so negative chunk coordinates map to the region below rather than toward zero: chunk `-1` belongs to region `-1`, not region `0`.
|
||||
#[must_use]
|
||||
pub fn region_coords(cx: i32, cy: i32, cz: i32) -> (i32, i32, i32) {
|
||||
(
|
||||
cx.div_euclid(REGION_SIZE),
|
||||
cy.div_euclid(REGION_SIZE),
|
||||
cz.div_euclid(REGION_SIZE),
|
||||
)
|
||||
}
|
||||
|
||||
/// Builds the on-disk path of the region file containing chunk `(cx, cy, cz)` within `dir`.
|
||||
#[must_use]
|
||||
pub fn region_path(dir: &Path, cx: i32, cy: i32, cz: i32) -> PathBuf {
|
||||
let (rx, ry, rz) = region_coords(cx, cy, cz);
|
||||
dir.join(format!("r.{rx}.{ry}.{rz}.region"))
|
||||
}
|
||||
|
||||
/// An open region file: its `SYNR` index, the resident chunk records, and its on-disk location.
|
||||
pub struct RegionFile {
|
||||
/// The region framing (header table, free list, stamp table) held in memory.
|
||||
index: RegionIndex,
|
||||
/// Each resident chunk's raw `SYNC` record bytes, decoded lazily on read.
|
||||
records: BTreeMap<ChunkPos, Vec<u8>>,
|
||||
/// The path this region is read from and written back to.
|
||||
path: PathBuf,
|
||||
/// Whether an in-memory mutation is pending a flush to disk.
|
||||
dirty: bool,
|
||||
}
|
||||
|
||||
impl RegionFile {
|
||||
/// Opens the region file at `path`, or yields an empty region if the file does not yet exist.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`SaveError::Io`] if the file cannot be read, a decoding error from [`RegionIndex::decode`] if the index is malformed, or [`SaveError::PayloadTooLarge`] / [`SaveError::Truncated`] if a header entry's span falls outside the file.
|
||||
pub fn open(path: PathBuf) -> Result<Self, SaveError> {
|
||||
if !path.exists() {
|
||||
return Ok(Self {
|
||||
index: RegionIndex::new(0),
|
||||
records: BTreeMap::new(),
|
||||
path,
|
||||
dirty: false,
|
||||
});
|
||||
}
|
||||
|
||||
let bytes = fs::read(&path)?;
|
||||
let index = RegionIndex::decode(&bytes)?;
|
||||
|
||||
// Slice each record out of the file by the absolute (offset, length) the header table records. On-disk bytes are untrusted, so an out-of-range span is rejected rather than panicking on the slice.
|
||||
let mut records = BTreeMap::new();
|
||||
for (pos, entry) in index.entries() {
|
||||
// Widening u32 -> usize is lossless on every supported (64-bit) target; the u64 offset is range-checked by try_from, failing loudly on a 32-bit target rather than wrapping.
|
||||
let start = usize::try_from(entry.offset).map_err(|_| SaveError::PayloadTooLarge {
|
||||
len: entry.length as usize,
|
||||
})?;
|
||||
let end =
|
||||
start
|
||||
.checked_add(entry.length as usize)
|
||||
.ok_or(SaveError::PayloadTooLarge {
|
||||
len: entry.length as usize,
|
||||
})?;
|
||||
let record_bytes = bytes
|
||||
.get(start..end)
|
||||
.ok_or(SaveError::Truncated {
|
||||
offset: start,
|
||||
needed: entry.length as usize,
|
||||
available: bytes.len().saturating_sub(start),
|
||||
})?
|
||||
.to_vec();
|
||||
records.insert(*pos, record_bytes);
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
index,
|
||||
records,
|
||||
path,
|
||||
dirty: false,
|
||||
})
|
||||
}
|
||||
|
||||
/// Returns the number of resident chunk records.
|
||||
#[must_use]
|
||||
pub fn len(&self) -> usize {
|
||||
self.records.len()
|
||||
}
|
||||
|
||||
/// Returns whether the region holds no chunk records.
|
||||
#[must_use]
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.records.is_empty()
|
||||
}
|
||||
|
||||
/// Whether an in-memory mutation is pending a flush to disk.
|
||||
#[must_use]
|
||||
pub fn is_dirty(&self) -> bool {
|
||||
self.dirty
|
||||
}
|
||||
|
||||
/// Decodes and returns the chunk at `pos`, or `None` if the region holds no record for it.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns a decoding error from [`record::decode`] if the stored record is malformed.
|
||||
pub fn read_chunk(&self, pos: ChunkPos) -> Result<Option<ChunkData>, SaveError> {
|
||||
match self.records.get(&pos) {
|
||||
Some(bytes) => {
|
||||
let (_meta, data) = record::decode(bytes)?;
|
||||
Ok(Some(data))
|
||||
}
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// Encodes `data` into a `SYNC` record stamped with `last_modified` and stores it under `pos`.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an encoding error from [`record::encode`] if serialization fails, or [`SaveError::PayloadTooLarge`] if the encoded record exceeds `u32::MAX` bytes.
|
||||
pub fn write_chunk(
|
||||
&mut self,
|
||||
pos: ChunkPos,
|
||||
data: &ChunkData,
|
||||
last_modified: u64,
|
||||
) -> Result<(), SaveError> {
|
||||
let bytes = record::encode(data, last_modified)?;
|
||||
let length = u32::try_from(bytes.len())
|
||||
.map_err(|_| SaveError::PayloadTooLarge { len: bytes.len() })?;
|
||||
self.records.insert(pos, bytes);
|
||||
self.index.insert(
|
||||
pos,
|
||||
HeaderEntry {
|
||||
offset: 0,
|
||||
length,
|
||||
flags: 0,
|
||||
},
|
||||
);
|
||||
self.dirty = true;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Removes the chunk at `pos` from the region, if present.
|
||||
pub fn remove_chunk(&mut self, pos: ChunkPos) {
|
||||
let removed = self.records.remove(&pos).is_some();
|
||||
self.index.remove(pos);
|
||||
if removed {
|
||||
self.dirty = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// Flushes the region to disk with a crash-safe whole-file atomic rewrite, clearing the dirty flag.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`SaveError::PayloadTooLarge`] if a record's length exceeds `u32::MAX`, or [`SaveError::Io`] if the atomic write to disk fails.
|
||||
pub fn save(&mut self) -> Result<(), SaveError> {
|
||||
let image = self.serialize()?;
|
||||
atomic_write(&self.path, &image)?;
|
||||
self.dirty = false;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Builds the complete on-disk file image: the encoded index followed by every record.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`SaveError::PayloadTooLarge`] if the index or any record length exceeds `u32::MAX` bytes.
|
||||
// * NOTE: this is a whole-file rewrite. The right way to do it for large saves is to append changed records into free space and rewriting only the header table, so save cost scales with chunks modified rather than total file size. The free list and absolute offsets already on disk support that switch without a format change.
|
||||
// TODO: incremental save.
|
||||
fn serialize(&mut self) -> Result<Vec<u8>, SaveError> {
|
||||
let index_len = self.index.encode()?.len();
|
||||
|
||||
// Assign each record a contiguous offset in ascending position order (BTreeMap order), the same order the records are concatenated below.
|
||||
let mut offset = index_len as u64;
|
||||
for (pos, bytes) in &self.records {
|
||||
let length = u32::try_from(bytes.len())
|
||||
.map_err(|_| SaveError::PayloadTooLarge { len: bytes.len() })?;
|
||||
self.index.insert(
|
||||
*pos,
|
||||
HeaderEntry {
|
||||
offset,
|
||||
length,
|
||||
flags: 0,
|
||||
},
|
||||
);
|
||||
offset += bytes.len() as u64;
|
||||
}
|
||||
|
||||
let mut image = self.index.encode()?;
|
||||
for bytes in self.records.values() {
|
||||
image.extend_from_slice(bytes);
|
||||
}
|
||||
Ok(image)
|
||||
}
|
||||
}
|
||||
|
||||
/// Writes `bytes` to `path` via the POSIX atomic-write pattern: `.tmp` + fsync + rename.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`SaveError::Io`] if the parent directory cannot be created, or if writing, syncing, or renaming the temporary file fails.
|
||||
fn atomic_write(path: &Path, bytes: &[u8]) -> Result<(), SaveError> {
|
||||
// The region directory is created on demand so the first write to a fresh world succeeds.
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
|
||||
// The temp path appends ".tmp" to the full filename (rather than replacing the extension) so it sits beside the target on the same filesystem, keeping the rename atomic.
|
||||
let mut tmp_name: OsString = path.as_os_str().to_owned();
|
||||
tmp_name.push(".tmp");
|
||||
let tmp_path = PathBuf::from(tmp_name);
|
||||
|
||||
let mut file = File::create(&tmp_path)?;
|
||||
file.write_all(bytes)?;
|
||||
// fsync the data to disk before the rename, so the rename cannot expose an unwritten file.
|
||||
file.sync_all()?;
|
||||
drop(file);
|
||||
|
||||
fs::rename(&tmp_path, path)?;
|
||||
|
||||
// fsync the parent directory so the rename itself is durable. Opening a directory for fsync is a Unix affordance; Windows does not expose a directory handle to sync, so the step is skipped there.
|
||||
#[cfg(unix)]
|
||||
if let Some(parent) = path.parent() {
|
||||
File::open(parent)?.sync_all()?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "../tests/region_file.rs"]
|
||||
mod tests;
|
||||
40
crates/server/src/stats.rs
Normal file
40
crates/server/src/stats.rs
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
//! Formatting of the periodic simulation statistics report.
|
||||
|
||||
use std::fmt::Write as _;
|
||||
|
||||
use shared::protocol::authority::ServerStats;
|
||||
|
||||
/// Renders one measurement window's diagnostics as a multi-line panel.
|
||||
#[must_use]
|
||||
pub fn format_panel(stats: &ServerStats) -> String {
|
||||
let mut out = String::with_capacity(256);
|
||||
|
||||
// `write!` into a String cannot fail, so the results are discarded rather than propagated.
|
||||
let _ = writeln!(out, "── server statistics ──");
|
||||
let _ = writeln!(
|
||||
out,
|
||||
"tick {:.1} tps mean {:.2} ms max {:.2} ms budget {:.0}% uptime {} s",
|
||||
stats.measured_tps,
|
||||
stats.mean_tick_ms,
|
||||
stats.max_tick_ms,
|
||||
stats.tick_budget_percent,
|
||||
stats.uptime_secs
|
||||
);
|
||||
let _ = write!(
|
||||
out,
|
||||
"world chunks {} resident / {} in flight clients {} entities {} players {}",
|
||||
stats.loaded_chunks,
|
||||
stats.chunks_in_flight,
|
||||
stats.connected_clients,
|
||||
stats.entities,
|
||||
stats.players
|
||||
);
|
||||
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tests/stats.rs"]
|
||||
mod tests;
|
||||
50
crates/server/src/tests/chunk_cache.rs
Normal file
50
crates/server/src/tests/chunk_cache.rs
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
use super::*;
|
||||
use shared::generator::{VoxelGenerator, WorldGenConfig};
|
||||
use shared::world::{BlockId, ChunkPos};
|
||||
use std::num::NonZeroUsize;
|
||||
|
||||
/// Builds a generator with a small, cheap terrain configuration for cache tests.
|
||||
fn test_generator() -> VoxelGenerator {
|
||||
let config = WorldGenConfig {
|
||||
base_height: 8,
|
||||
noise_scale: 0.05,
|
||||
surface_block: BlockId(1),
|
||||
subsurface_block: BlockId(2),
|
||||
stone_block: BlockId(3),
|
||||
};
|
||||
VoxelGenerator::new(config, 42)
|
||||
}
|
||||
|
||||
/// A second lookup of the same position must be served from the store, not regenerated.
|
||||
#[test]
|
||||
fn repeated_lookup_is_a_cache_hit() {
|
||||
let generator = test_generator();
|
||||
let cache = ChunkCache::new(NonZeroUsize::new(4).unwrap_or(NonZeroUsize::MIN));
|
||||
let pos = ChunkPos::new(0, 0, 0);
|
||||
|
||||
let first = cache.get_or_generate(pos, &generator);
|
||||
let second = cache.get_or_generate(pos, &generator);
|
||||
|
||||
// Determinism guarantees identical output, and a single resident entry can only hold if the second call was a hit rather than a fresh generation-and-insert of a distinct value.
|
||||
assert_eq!(first.blocks, second.blocks);
|
||||
assert_eq!(cache.len(), 1);
|
||||
}
|
||||
|
||||
/// Inserting beyond capacity evicts the least-recently-used entry.
|
||||
#[test]
|
||||
fn exceeding_capacity_evicts_oldest() {
|
||||
let generator = test_generator();
|
||||
let cache = ChunkCache::new(NonZeroUsize::new(2).unwrap_or(NonZeroUsize::MIN));
|
||||
|
||||
let _ = cache.get_or_generate(ChunkPos::new(0, 0, 0), &generator);
|
||||
let _ = cache.get_or_generate(ChunkPos::new(1, 0, 0), &generator);
|
||||
// Touch the first so the second becomes the least-recently-used before the overflow.
|
||||
let _ = cache.get_or_generate(ChunkPos::new(0, 0, 0), &generator);
|
||||
let _ = cache.get_or_generate(ChunkPos::new(2, 0, 0), &generator);
|
||||
|
||||
assert_eq!(cache.len(), 2);
|
||||
assert!(cache.contains(ChunkPos::new(0, 0, 0)));
|
||||
assert!(!cache.contains(ChunkPos::new(1, 0, 0)));
|
||||
}
|
||||
85
crates/server/src/tests/client_stream.rs
Normal file
85
crates/server/src/tests/client_stream.rs
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
//! Unit tests for the per-client desired-set diff.
|
||||
|
||||
use super::*;
|
||||
use crate::world_server::cylinder_chunks;
|
||||
|
||||
/// Builds the streaming cylinder around `center` at `radius` as a set, mirroring what a subscription produces.
|
||||
fn cylinder(center: ChunkPos, radius: i32) -> HashSet<ChunkPos> {
|
||||
let mut set = HashSet::new();
|
||||
cylinder_chunks(center, radius, &mut set);
|
||||
set
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn diff_of_equal_sets_is_empty() {
|
||||
let set = cylinder(ChunkPos::new(0, 0, 0), 3);
|
||||
let diff = desired_diff(&set, &set);
|
||||
assert!(
|
||||
diff.added.is_empty(),
|
||||
"no chunks are added when the set is unchanged"
|
||||
);
|
||||
assert!(
|
||||
diff.removed.is_empty(),
|
||||
"no chunks are removed when the set is unchanged"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_empty_previous_adds_all_new() {
|
||||
let new = cylinder(ChunkPos::new(5, 0, -2), 2);
|
||||
let diff = desired_diff(&HashSet::new(), &new);
|
||||
assert_eq!(
|
||||
diff.added.len(),
|
||||
new.len(),
|
||||
"an initial subscribe adds the whole set"
|
||||
);
|
||||
assert!(
|
||||
diff.removed.is_empty(),
|
||||
"an initial subscribe removes nothing"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn center_move_by_one_chunk_swaps_symmetric_shells() {
|
||||
let previous = cylinder(ChunkPos::new(0, 0, 0), 3);
|
||||
let new = cylinder(ChunkPos::new(1, 0, 0), 3);
|
||||
let diff = desired_diff(&previous, &new);
|
||||
|
||||
// Every added chunk is genuinely new; every removed chunk genuinely left.
|
||||
for pos in &diff.added {
|
||||
assert!(
|
||||
new.contains(pos) && !previous.contains(pos),
|
||||
"added chunks are new-only"
|
||||
);
|
||||
}
|
||||
for pos in &diff.removed {
|
||||
assert!(
|
||||
previous.contains(pos) && !new.contains(pos),
|
||||
"removed chunks are previous-only"
|
||||
);
|
||||
}
|
||||
|
||||
// A one-chunk shift keeps the overlap resident, so neither list is the whole set.
|
||||
assert!(!diff.added.is_empty() && diff.added.len() < new.len());
|
||||
assert!(!diff.removed.is_empty());
|
||||
|
||||
// The cylinder is translation-invariant in size, so a shift moves equal counts in and out.
|
||||
assert_eq!(diff.added.len(), diff.removed.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn added_and_removed_are_sorted() {
|
||||
let previous = cylinder(ChunkPos::new(0, 0, 0), 2);
|
||||
let new = cylinder(ChunkPos::new(2, 1, 0), 2);
|
||||
let diff = desired_diff(&previous, &new);
|
||||
assert!(
|
||||
diff.added.windows(2).all(|w| w[0] <= w[1]),
|
||||
"added list is sorted"
|
||||
);
|
||||
assert!(
|
||||
diff.removed.windows(2).all(|w| w[0] <= w[1]),
|
||||
"removed list is sorted"
|
||||
);
|
||||
}
|
||||
136
crates/server/src/tests/region_file.rs
Normal file
136
crates/server/src/tests/region_file.rs
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
use super::*;
|
||||
use shared::world::BlockId;
|
||||
|
||||
/// Builds a representative modified chunk with a few edits spanning the local index range.
|
||||
fn sample(pos: ChunkPos) -> ChunkData {
|
||||
let mut data = ChunkData::new(pos, 7);
|
||||
data.set(0, BlockId(4));
|
||||
data.set(1000, BlockId(9));
|
||||
data.set(32_767, BlockId(2));
|
||||
data
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn region_coords_floor_negative_chunks() {
|
||||
// Truncating division would map -1 to region 0; Euclidean flooring maps it to region -1. Every axis, including Y, floors identically under the 3D region grid.
|
||||
assert_eq!(region_coords(0, 0, 0), (0, 0, 0));
|
||||
assert_eq!(region_coords(31, 31, 31), (0, 0, 0));
|
||||
assert_eq!(region_coords(-1, -1, -1), (-1, -1, -1));
|
||||
assert_eq!(region_coords(-32, -33, 64), (-1, -2, 2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn region_path_names_the_region_file() {
|
||||
let dir = Path::new("/saves/world/region");
|
||||
assert_eq!(
|
||||
region_path(dir, -1, 40, 5),
|
||||
Path::new("/saves/world/region/r.-1.1.0.region")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_missing_file_is_empty() -> Result<(), SaveError> {
|
||||
let dir = tempfile::tempdir()?;
|
||||
let region = RegionFile::open(dir.path().join("r.0.0.0.region"))?;
|
||||
assert!(region.is_empty());
|
||||
assert_eq!(region.read_chunk(ChunkPos::new(0, 0, 0))?, None);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trips_chunks_through_disk() -> Result<(), SaveError> {
|
||||
let dir = tempfile::tempdir()?;
|
||||
let path = dir.path().join("r.0.0.0.region");
|
||||
|
||||
let positions = [
|
||||
ChunkPos::new(0, 0, 0),
|
||||
ChunkPos::new(1, 2, 3),
|
||||
ChunkPos::new(-5, 10, -30),
|
||||
];
|
||||
|
||||
let mut region = RegionFile::open(path.clone())?;
|
||||
for pos in positions {
|
||||
region.write_chunk(pos, &sample(pos), 123)?;
|
||||
}
|
||||
assert!(region.is_dirty());
|
||||
region.save()?;
|
||||
assert!(!region.is_dirty());
|
||||
|
||||
// Reopen from disk in a fresh instance and confirm every chunk decodes byte-identically.
|
||||
let reopened = RegionFile::open(path)?;
|
||||
assert_eq!(reopened.len(), positions.len());
|
||||
for pos in positions {
|
||||
assert_eq!(reopened.read_chunk(pos)?, Some(sample(pos)));
|
||||
}
|
||||
// A position never written has no record.
|
||||
assert_eq!(reopened.read_chunk(ChunkPos::new(9, 9, 9))?, None);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn record_offsets_are_valid_and_contiguous() -> Result<(), SaveError> {
|
||||
let dir = tempfile::tempdir()?;
|
||||
let path = dir.path().join("r.0.0.0.region");
|
||||
|
||||
let mut region = RegionFile::open(path.clone())?;
|
||||
for pos in [
|
||||
ChunkPos::new(0, 0, 0),
|
||||
ChunkPos::new(2, 0, 1),
|
||||
ChunkPos::new(-1, 4, -1),
|
||||
] {
|
||||
region.write_chunk(pos, &sample(pos), 0)?;
|
||||
}
|
||||
region.save()?;
|
||||
|
||||
let reopened = RegionFile::open(path)?;
|
||||
let index_len = reopened.index.encode()?.len() as u64;
|
||||
|
||||
// Records are packed contiguously immediately after the index, in ascending position order.
|
||||
let mut expected_offset = index_len;
|
||||
for (_pos, entry) in reopened.index.entries() {
|
||||
assert_eq!(entry.offset, expected_offset);
|
||||
expected_offset += u64::from(entry.length);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_drops_only_the_named_chunk() -> Result<(), SaveError> {
|
||||
let dir = tempfile::tempdir()?;
|
||||
let path = dir.path().join("r.0.0.0.region");
|
||||
let kept = ChunkPos::new(0, 0, 0);
|
||||
let dropped = ChunkPos::new(1, 1, 1);
|
||||
|
||||
let mut region = RegionFile::open(path.clone())?;
|
||||
region.write_chunk(kept, &sample(kept), 0)?;
|
||||
region.write_chunk(dropped, &sample(dropped), 0)?;
|
||||
region.save()?;
|
||||
|
||||
let mut region = RegionFile::open(path.clone())?;
|
||||
region.remove_chunk(dropped);
|
||||
region.save()?;
|
||||
|
||||
let reopened = RegionFile::open(path)?;
|
||||
assert_eq!(reopened.read_chunk(dropped)?, None);
|
||||
assert_eq!(reopened.read_chunk(kept)?, Some(sample(kept)));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stray_tmp_file_does_not_corrupt_reads() -> Result<(), SaveError> {
|
||||
let dir = tempfile::tempdir()?;
|
||||
let path = dir.path().join("r.0.0.0.region");
|
||||
let pos = ChunkPos::new(0, 0, 0);
|
||||
|
||||
let mut region = RegionFile::open(path.clone())?;
|
||||
region.write_chunk(pos, &sample(pos), 0)?;
|
||||
region.save()?;
|
||||
|
||||
// A leftover .tmp from an interrupted save must be ignored: only the renamed target is read.
|
||||
fs::write(dir.path().join("r.0.0.0.region.tmp"), b"garbage")?;
|
||||
let reopened = RegionFile::open(path)?;
|
||||
assert_eq!(reopened.read_chunk(pos)?, Some(sample(pos)));
|
||||
Ok(())
|
||||
}
|
||||
48
crates/server/src/tests/stats.rs
Normal file
48
crates/server/src/tests/stats.rs
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
//! Unit tests for the statistics panel formatter in [`crate::stats`].
|
||||
|
||||
use super::*;
|
||||
|
||||
/// Builds a snapshot with distinguishable values in every field.
|
||||
fn sample() -> ServerStats {
|
||||
ServerStats {
|
||||
measured_tps: 19.96,
|
||||
mean_tick_ms: 1.234,
|
||||
max_tick_ms: 4.567,
|
||||
tick_budget_percent: 24.7,
|
||||
loaded_chunks: 421,
|
||||
chunks_in_flight: 3,
|
||||
connected_clients: 2,
|
||||
entities: 5,
|
||||
players: 1,
|
||||
uptime_secs: 42,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_field_of_the_snapshot_reaches_the_panel() {
|
||||
let panel = format_panel(&sample());
|
||||
|
||||
assert!(panel.contains("20.0 tps"), "{panel}");
|
||||
assert!(panel.contains("mean 1.23 ms"), "{panel}");
|
||||
assert!(panel.contains("max 4.57 ms"), "{panel}");
|
||||
assert!(panel.contains("budget 25%"), "{panel}");
|
||||
assert!(panel.contains("uptime 42 s"), "{panel}");
|
||||
assert!(
|
||||
panel.contains("chunks 421 resident / 3 in flight"),
|
||||
"{panel}"
|
||||
);
|
||||
assert!(panel.contains("clients 2"), "{panel}");
|
||||
assert!(panel.contains("entities 5"), "{panel}");
|
||||
assert!(panel.contains("players 1"), "{panel}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_panel_is_a_header_and_two_rows_without_a_trailing_newline() {
|
||||
let panel = format_panel(&sample());
|
||||
|
||||
assert_eq!(panel.lines().count(), 3);
|
||||
// The caller supplies the leading newline, so a trailing one would open a blank line in the log.
|
||||
assert!(!panel.ends_with('\n'), "{panel}");
|
||||
}
|
||||
112
crates/server/src/tests/tick_stats.rs
Normal file
112
crates/server/src/tests/tick_stats.rs
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
//! Unit tests for the simulation loop's timing measurement.
|
||||
|
||||
use super::*;
|
||||
|
||||
/// The nominal 20 Hz period the server budgets each tick.
|
||||
const PERIOD: Duration = Duration::from_millis(50);
|
||||
|
||||
/// Asserts two f32 values agree to within a tolerance that survives the accumulated division and multiplication.
|
||||
fn close(actual: f32, expected: f32) {
|
||||
assert!(
|
||||
(actual - expected).abs() < 0.01,
|
||||
"expected {expected}, got {actual}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_window_reports_zeroes_rather_than_dividing_by_zero() {
|
||||
let window = summarise(0, Duration::ZERO, Duration::ZERO, REPORT_INTERVAL, PERIOD);
|
||||
|
||||
close(window.measured_tps, 0.0);
|
||||
close(window.mean_tick_ms, 0.0);
|
||||
close(window.tick_budget_percent, 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_window_at_the_nominal_rate_reports_the_nominal_rate() {
|
||||
// Twenty ticks of 10 ms each, filling exactly one second of wall clock.
|
||||
let window = summarise(
|
||||
20,
|
||||
Duration::from_millis(200),
|
||||
Duration::from_millis(10),
|
||||
Duration::from_secs(1),
|
||||
PERIOD,
|
||||
);
|
||||
|
||||
close(window.measured_tps, 20.0);
|
||||
close(window.mean_tick_ms, 10.0);
|
||||
close(window.max_tick_ms, 10.0);
|
||||
// 10 ms of a 50 ms budget is one fifth of the period.
|
||||
close(window.tick_budget_percent, 20.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_overrunning_server_reports_a_rate_below_nominal() {
|
||||
// Ten ticks of 100 ms each: the body alone exceeds the 50 ms budget, so only half the nominal count fits in the second.
|
||||
let window = summarise(
|
||||
10,
|
||||
Duration::from_secs(1),
|
||||
Duration::from_millis(140),
|
||||
Duration::from_secs(1),
|
||||
PERIOD,
|
||||
);
|
||||
|
||||
close(window.measured_tps, 10.0);
|
||||
close(window.mean_tick_ms, 100.0);
|
||||
close(window.tick_budget_percent, 200.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_maximum_is_reported_separately_from_the_mean() {
|
||||
// Nine cheap ticks and one stall: the mean stays inside budget while the maximum does not.
|
||||
let window = summarise(
|
||||
10,
|
||||
Duration::from_millis(100),
|
||||
Duration::from_millis(91),
|
||||
Duration::from_secs(1),
|
||||
PERIOD,
|
||||
);
|
||||
|
||||
close(window.mean_tick_ms, 10.0);
|
||||
close(window.max_tick_ms, 91.0);
|
||||
assert!(window.tick_budget_percent < 100.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_window_closes_only_once_the_interval_has_elapsed() {
|
||||
let start = Instant::now();
|
||||
let mut meter = TickMeter::new(start, PERIOD);
|
||||
meter.record(Duration::from_millis(10));
|
||||
|
||||
assert!(
|
||||
meter
|
||||
.take_window(start + Duration::from_millis(999))
|
||||
.is_none()
|
||||
);
|
||||
assert!(meter.take_window(start + REPORT_INTERVAL).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closing_a_window_resets_the_accumulators() {
|
||||
let start = Instant::now();
|
||||
let mut meter = TickMeter::new(start, PERIOD);
|
||||
meter.record(Duration::from_millis(40));
|
||||
let _ = meter.take_window(start + REPORT_INTERVAL);
|
||||
|
||||
meter.record(Duration::from_millis(10));
|
||||
let second = meter
|
||||
.take_window(start + REPORT_INTERVAL + REPORT_INTERVAL)
|
||||
.unwrap_or(TickWindow {
|
||||
measured_tps: 0.0,
|
||||
mean_tick_ms: 0.0,
|
||||
max_tick_ms: 0.0,
|
||||
tick_budget_percent: 0.0,
|
||||
});
|
||||
|
||||
// The 40 ms tick belonged to the first window and must not leak into the second's maximum.
|
||||
close(second.mean_tick_ms, 10.0);
|
||||
close(second.max_tick_ms, 10.0);
|
||||
close(second.measured_tps, 1.0);
|
||||
}
|
||||
213
crates/server/src/tests/world_server.rs
Normal file
213
crates/server/src/tests/world_server.rs
Normal file
|
|
@ -0,0 +1,213 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
use super::*;
|
||||
use shared::generator::{VoxelGenerator, WorldGenConfig};
|
||||
use shared::save::SaveError;
|
||||
use shared::world::{BlockId, ChunkData, ChunkPos};
|
||||
use std::collections::HashSet;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use crate::save::{RegionFile, SaveRequest, region_path};
|
||||
|
||||
/// Builds a generator with a small, cheap terrain configuration for streaming tests.
|
||||
fn test_generator() -> VoxelGenerator {
|
||||
let config = WorldGenConfig {
|
||||
base_height: 8,
|
||||
noise_scale: 0.05,
|
||||
surface_block: BlockId(1),
|
||||
subsurface_block: BlockId(2),
|
||||
stone_block: BlockId(3),
|
||||
};
|
||||
VoxelGenerator::new(config, 42)
|
||||
}
|
||||
|
||||
/// Builds a server world whose saves resolve against `region_dir`, backed by a small baseline cache.
|
||||
fn test_world(region_dir: std::path::PathBuf) -> ServerWorld {
|
||||
let capacity = std::num::NonZeroUsize::new(64).unwrap_or(std::num::NonZeroUsize::MIN);
|
||||
ServerWorld::new(test_generator(), region_dir, capacity)
|
||||
}
|
||||
|
||||
/// Repeatedly reconciles `desired` until the worker pool reports no outstanding work, returning the final pass's stats. Fails the test if the pool does not drain within a fixed timeout.
|
||||
fn drain_to_idle(world: &mut ServerWorld, desired: &HashSet<ChunkPos>) -> StreamStats {
|
||||
let deadline = Instant::now() + Duration::from_secs(5);
|
||||
loop {
|
||||
let stats = world.reconcile(desired);
|
||||
if stats.in_flight == 0 {
|
||||
return stats;
|
||||
}
|
||||
assert!(
|
||||
Instant::now() < deadline,
|
||||
"worker pool did not drain in time"
|
||||
);
|
||||
std::thread::sleep(Duration::from_millis(1));
|
||||
}
|
||||
}
|
||||
|
||||
/// Issues a flush against the world's save actor and blocks until every dirty region is written. Because write-backs and this flush travel the same sender to the single actor thread, the reply confirms the preceding writes are durable.
|
||||
fn flush(world: &ServerWorld) -> Result<(), SaveError> {
|
||||
let (reply_tx, reply_rx) = crossbeam_channel::bounded(1);
|
||||
// A send error means the actor has already stopped, leaving nothing to flush.
|
||||
if world
|
||||
.save_tx
|
||||
.send(SaveRequest::Flush { reply: reply_tx })
|
||||
.is_err()
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
reply_rx.recv().unwrap_or(Ok(()))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reconcile_converges_over_multiple_passes() -> Result<(), SaveError> {
|
||||
// A fresh empty directory means every load is a miss and resolves to the baseline.
|
||||
let dir = tempfile::tempdir()?;
|
||||
let mut world = test_world(dir.path().to_path_buf());
|
||||
let mut desired = HashSet::new();
|
||||
cylinder_chunks(ChunkPos::new(0, 0, 0), 2, &mut desired);
|
||||
|
||||
// The first pass only dispatches work; because loading is off-thread, nothing is resident yet and every position is in flight.
|
||||
let first = world.reconcile(&desired);
|
||||
assert_eq!(first.loaded, 0);
|
||||
assert_eq!(first.resident, 0);
|
||||
assert!(first.in_flight > 0);
|
||||
|
||||
// Later passes drain finished chunks until the pool is idle, at which point every desired position must be resident.
|
||||
let final_stats = drain_to_idle(&mut world, &desired);
|
||||
assert_eq!(final_stats.in_flight, 0);
|
||||
assert_eq!(final_stats.resident, desired.len());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn evicted_chunk_is_not_repopulated_on_arrival() -> Result<(), SaveError> {
|
||||
let dir = tempfile::tempdir()?;
|
||||
let mut world = test_world(dir.path().to_path_buf());
|
||||
let target = ChunkPos::new(0, 0, 0);
|
||||
let mut desired = HashSet::new();
|
||||
desired.insert(target);
|
||||
|
||||
// Dispatch the chunk, then immediately stop wanting it.
|
||||
world.reconcile(&desired);
|
||||
|
||||
// Every subsequent pass reconciles against an empty desired set, so the finished chunk is discarded on arrival rather than inserted.
|
||||
let empty = HashSet::new();
|
||||
let final_stats = drain_to_idle(&mut world, &empty);
|
||||
assert_eq!(final_stats.in_flight, 0);
|
||||
assert_eq!(final_stats.resident, 0);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saved_modification_is_applied_over_baseline() -> Result<(), SaveError> {
|
||||
// A modified chunk is written to disk, then streamed back; the resident chunk must show the edit rather than the bare baseline.
|
||||
let dir = tempfile::tempdir()?;
|
||||
let pos = ChunkPos::new(0, 0, 0);
|
||||
let edited_index = 100u32;
|
||||
let edited_block = BlockId(999);
|
||||
|
||||
let mut data = ChunkData::new(pos, 0);
|
||||
data.set(edited_index, edited_block);
|
||||
|
||||
let mut region = RegionFile::open(region_path(dir.path(), pos.x, pos.y, pos.z))?;
|
||||
region.write_chunk(pos, &data, 0)?;
|
||||
region.save()?;
|
||||
|
||||
let mut world = test_world(dir.path().to_path_buf());
|
||||
let mut desired = HashSet::new();
|
||||
desired.insert(pos);
|
||||
drain_to_idle(&mut world, &desired);
|
||||
|
||||
// The resident chunk must carry the stored edit layered over its regenerated baseline.
|
||||
assert!(
|
||||
world
|
||||
.chunk(pos)
|
||||
.is_some_and(|chunk| chunk.blocks[edited_index as usize] == edited_block)
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dirty_chunk_is_written_back_on_eviction() -> Result<(), SaveError> {
|
||||
// A resident chunk edited away from its baseline must survive an evict -> flush -> reload round-trip.
|
||||
let dir = tempfile::tempdir()?;
|
||||
let pos = ChunkPos::new(0, 0, 0);
|
||||
let edited_index = 100usize;
|
||||
let edited_block = BlockId(999);
|
||||
|
||||
let mut world = test_world(dir.path().to_path_buf());
|
||||
let mut desired = HashSet::new();
|
||||
desired.insert(pos);
|
||||
drain_to_idle(&mut world, &desired);
|
||||
|
||||
// Mutate the resident chunk so it diverges from the baseline the eviction diff regenerates.
|
||||
assert!(
|
||||
world
|
||||
.chunks
|
||||
.get_mut(&pos)
|
||||
.map(|chunk| chunk.blocks[edited_index] = edited_block)
|
||||
.is_some()
|
||||
);
|
||||
|
||||
// Reconciling against an empty desired set evicts the chunk, sending its diff to the actor.
|
||||
world.reconcile(&HashSet::new());
|
||||
// The flush shares the eviction's sender, so its reply confirms the write-back is on disk.
|
||||
flush(&world)?;
|
||||
|
||||
// A fresh world over the same directory must stream the chunk back with the edit intact.
|
||||
let mut reloaded = test_world(dir.path().to_path_buf());
|
||||
drain_to_idle(&mut reloaded, &desired);
|
||||
assert!(
|
||||
reloaded
|
||||
.chunk(pos)
|
||||
.is_some_and(|chunk| chunk.blocks[edited_index] == edited_block)
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clean_chunk_is_not_written_back_on_eviction() -> Result<(), SaveError> {
|
||||
// An unmodified chunk equals its baseline, so eviction must persist no record for it.
|
||||
let dir = tempfile::tempdir()?;
|
||||
let pos = ChunkPos::new(0, 0, 0);
|
||||
|
||||
let mut world = test_world(dir.path().to_path_buf());
|
||||
let mut desired = HashSet::new();
|
||||
desired.insert(pos);
|
||||
drain_to_idle(&mut world, &desired);
|
||||
|
||||
// Evict without modifying the chunk, then flush.
|
||||
world.reconcile(&HashSet::new());
|
||||
flush(&world)?;
|
||||
|
||||
// No record may exist for a chunk that never diverged from its baseline.
|
||||
let region = RegionFile::open(region_path(dir.path(), pos.x, pos.y, pos.z))?;
|
||||
assert!(region.read_chunk(pos)?.is_none());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cylinder_contains_expected_columns() {
|
||||
let mut set = HashSet::new();
|
||||
cylinder_chunks(ChunkPos::new(0, 0, 0), 2, &mut set);
|
||||
|
||||
assert!(set.contains(&ChunkPos::new(0, 0, 0)));
|
||||
// A corner cell is outside the disc (dx=2, dz=2 -> 8 > 4).
|
||||
assert!(!set.contains(&ChunkPos::new(2, 0, 2)));
|
||||
// An axis cell at exactly the radius is included (dx=2, dz=0 -> 4 == 4).
|
||||
assert!(set.contains(&ChunkPos::new(2, 0, 0)));
|
||||
// The vertical extent is radius/2 = 1, so y=2 is out of range.
|
||||
assert!(!set.contains(&ChunkPos::new(0, 2, 0)));
|
||||
assert!(set.contains(&ChunkPos::new(0, 1, 0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cylinder_translates_with_center() {
|
||||
let mut origin = HashSet::new();
|
||||
cylinder_chunks(ChunkPos::new(0, 0, 0), 3, &mut origin);
|
||||
|
||||
let mut shifted = HashSet::new();
|
||||
cylinder_chunks(ChunkPos::new(10, 0, -5), 3, &mut shifted);
|
||||
|
||||
// The shape is translation-invariant: the same count regardless of center.
|
||||
assert_eq!(origin.len(), shifted.len());
|
||||
}
|
||||
115
crates/server/src/tick_stats.rs
Normal file
115
crates/server/src/tick_stats.rs
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
//! Measurement of the simulation loop's own timing.
|
||||
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// Wall-clock cadence at which a measurement window closes and a report is produced.
|
||||
///
|
||||
/// One second is short enough to surface a stall promptly and long enough that the report costs nothing next to the ticks it summarises.
|
||||
pub const REPORT_INTERVAL: Duration = Duration::from_secs(1);
|
||||
|
||||
/// The summary produced when a measurement window closes.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct TickWindow {
|
||||
/// Ticks completed in the window, expressed per second.
|
||||
pub measured_tps: f32,
|
||||
/// Mean duration of a tick body in the window, in milliseconds.
|
||||
pub mean_tick_ms: f32,
|
||||
/// Longest tick body in the window, in milliseconds.
|
||||
pub max_tick_ms: f32,
|
||||
/// Share of the nominal tick period consumed by the mean tick body, in percent.
|
||||
pub tick_budget_percent: f32,
|
||||
}
|
||||
|
||||
/// Accumulates tick timings and closes a measurement window on a fixed cadence.
|
||||
#[derive(Debug)]
|
||||
pub struct TickMeter {
|
||||
/// Nominal period one tick is budgeted, against which utilisation is computed.
|
||||
period: Duration,
|
||||
/// Instant the current window opened; the window closes once [`REPORT_INTERVAL`] has elapsed from here.
|
||||
window_start: Instant,
|
||||
/// Tick bodies recorded in the current window.
|
||||
ticks: u32,
|
||||
/// Summed duration of every tick body in the current window.
|
||||
total: Duration,
|
||||
/// Longest single tick body in the current window.
|
||||
max: Duration,
|
||||
}
|
||||
|
||||
impl TickMeter {
|
||||
/// Opens the first measurement window at `now`, budgeting each tick `period`.
|
||||
#[must_use]
|
||||
pub fn new(now: Instant, period: Duration) -> Self {
|
||||
Self {
|
||||
period,
|
||||
window_start: now,
|
||||
ticks: 0,
|
||||
total: Duration::ZERO,
|
||||
max: Duration::ZERO,
|
||||
}
|
||||
}
|
||||
|
||||
/// Records one completed tick body of duration `elapsed`.
|
||||
pub fn record(&mut self, elapsed: Duration) {
|
||||
self.ticks = self.ticks.saturating_add(1);
|
||||
self.total = self.total.saturating_add(elapsed);
|
||||
self.max = self.max.max(elapsed);
|
||||
}
|
||||
|
||||
/// Closes the window and returns its summary once [`REPORT_INTERVAL`] has elapsed since it opened, otherwise returns [`None`].
|
||||
///
|
||||
/// On close the accumulators reset and a fresh window opens at `now`, so windows tile the timeline without gaps or overlap.
|
||||
pub fn take_window(&mut self, now: Instant) -> Option<TickWindow> {
|
||||
let elapsed = now.saturating_duration_since(self.window_start);
|
||||
if elapsed < REPORT_INTERVAL {
|
||||
return None;
|
||||
}
|
||||
|
||||
let window = summarise(self.ticks, self.total, self.max, elapsed, self.period);
|
||||
self.window_start = now;
|
||||
self.ticks = 0;
|
||||
self.total = Duration::ZERO;
|
||||
self.max = Duration::ZERO;
|
||||
Some(window)
|
||||
}
|
||||
}
|
||||
|
||||
/// Derives a window summary from its raw accumulators.
|
||||
///
|
||||
/// Split out from [`TickMeter::take_window`] so the arithmetic is exercisable without driving a clock. A window containing no ticks reports zeroes throughout rather than dividing by zero, which is the correct reading of "nothing completed".
|
||||
fn summarise(
|
||||
ticks: u32,
|
||||
total: Duration,
|
||||
max: Duration,
|
||||
elapsed: Duration,
|
||||
period: Duration,
|
||||
) -> TickWindow {
|
||||
if ticks == 0 || elapsed.is_zero() {
|
||||
return TickWindow {
|
||||
measured_tps: 0.0,
|
||||
mean_tick_ms: 0.0,
|
||||
max_tick_ms: max.as_secs_f32() * 1000.0,
|
||||
tick_budget_percent: 0.0,
|
||||
};
|
||||
}
|
||||
|
||||
let mean = total.as_secs_f32() / f32::from(u16::try_from(ticks).unwrap_or(u16::MAX));
|
||||
let period_secs = period.as_secs_f32();
|
||||
|
||||
TickWindow {
|
||||
measured_tps: f32::from(u16::try_from(ticks).unwrap_or(u16::MAX)) / elapsed.as_secs_f32(),
|
||||
mean_tick_ms: mean * 1000.0,
|
||||
max_tick_ms: max.as_secs_f32() * 1000.0,
|
||||
// A zero period would mean no budget exists to consume, so utilisation is undefined and reported as zero.
|
||||
tick_budget_percent: if period_secs > 0.0 {
|
||||
mean / period_secs * 100.0
|
||||
} else {
|
||||
0.0
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tests/tick_stats.rs"]
|
||||
mod tests;
|
||||
278
crates/server/src/world_server.rs
Normal file
278
crates/server/src/world_server.rs
Normal file
|
|
@ -0,0 +1,278 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
//! Authoritative chunk storage and generation logic for the server.
|
||||
|
||||
use bevy_ecs::prelude::Resource;
|
||||
use crossbeam_channel::{Receiver, Sender};
|
||||
use shared::{
|
||||
generator::VoxelGenerator,
|
||||
save::SaveError,
|
||||
world::{Chunk, ChunkData, ChunkPos},
|
||||
};
|
||||
use std::{
|
||||
collections::{HashMap, HashSet},
|
||||
num::NonZeroUsize,
|
||||
path::PathBuf,
|
||||
sync::Arc,
|
||||
thread::JoinHandle,
|
||||
};
|
||||
use tracing::warn;
|
||||
|
||||
use crate::chunk_cache::ChunkCache;
|
||||
use crate::save::{SaveActor, SaveRequest};
|
||||
|
||||
/// Outcome of a single streaming reconcile pass, surfaced for logging and tests.
|
||||
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
|
||||
pub struct StreamStats {
|
||||
/// Number of chunks generated and inserted this pass.
|
||||
pub loaded: usize,
|
||||
/// Number of chunks evicted this pass.
|
||||
pub unloaded: usize,
|
||||
/// Number of chunks resident after the pass.
|
||||
pub resident: usize,
|
||||
/// Number of chunks in flight.
|
||||
pub in_flight: usize,
|
||||
}
|
||||
|
||||
/// The server's authoritative representation of the world.
|
||||
#[derive(Resource)]
|
||||
pub struct ServerWorld {
|
||||
/// Currently resident chunks, keyed by chunk-space position.
|
||||
chunks: HashMap<ChunkPos, Chunk>,
|
||||
/// Sending end of the job channel; the main thread pushes positions to load.
|
||||
job_tx: Sender<ChunkPos>,
|
||||
/// Receiving end of the result channel; the main thread drains finished chunks returned by workers.
|
||||
result_rx: Receiver<(ChunkPos, Chunk)>,
|
||||
/// Positions dispatched to a worker but not yet returned, preventing the same chunk being re-dispatched on subsequent passes.
|
||||
in_flight: HashSet<ChunkPos>,
|
||||
/// The dedicated thread owning all region files, kept alive for the world's lifetime.
|
||||
#[expect(
|
||||
dead_code,
|
||||
reason = "retained to keep the save request channel open for the workers"
|
||||
)]
|
||||
save_actor: SaveActor,
|
||||
/// Handles to the generation worker threads, retained so they can be joined on shutdown.
|
||||
// TODO: Remove once the server has a graceful-stop sequence
|
||||
#[expect(
|
||||
dead_code,
|
||||
reason = "retained for a future graceful-shutdown join path"
|
||||
)]
|
||||
workers: Vec<JoinHandle<()>>,
|
||||
/// The same read-only generator handle the workers share, held so eviction can regenerate a chunk's baseline to diff against.
|
||||
generator: Arc<VoxelGenerator>,
|
||||
/// A handle onto the shared baseline cache, used to resolve the baseline during the unload diff.
|
||||
cache: ChunkCache,
|
||||
/// The save actor's request sender, used to issue `Write` and `Remove` on unload.
|
||||
save_tx: Sender<SaveRequest>,
|
||||
}
|
||||
|
||||
impl ServerWorld {
|
||||
/// Initializes a new authoritative server world with the provided generator, resolving chunk loads against region files under `region_dir` and caching up to `cache_capacity` regenerated baselines.
|
||||
#[must_use]
|
||||
pub fn new(
|
||||
generator: VoxelGenerator,
|
||||
region_dir: PathBuf,
|
||||
cache_capacity: NonZeroUsize,
|
||||
) -> Self {
|
||||
let generator = Arc::new(generator);
|
||||
let (job_tx, job_rx) = crossbeam_channel::unbounded::<ChunkPos>();
|
||||
let (result_tx, result_rx) = crossbeam_channel::unbounded::<(ChunkPos, Chunk)>();
|
||||
|
||||
// The actor owns every region file; workers reach it only through cloned request senders.
|
||||
let save_actor = SaveActor::spawn(region_dir);
|
||||
|
||||
// Baselines are shared across the pool through cloned handles onto one bounded store.
|
||||
let cache = ChunkCache::new(cache_capacity);
|
||||
|
||||
let worker_count = std::thread::available_parallelism().map_or(4, std::num::NonZero::get);
|
||||
|
||||
let workers = (0..worker_count)
|
||||
.map(|_| {
|
||||
// Each worker shares a handle to the read-only generator, its own view of the shared job queue, its own sender back into the result channel, its own request sender to the save actor, and a handle onto the shared baseline cache.
|
||||
let generator = Arc::clone(&generator);
|
||||
let job_rx = job_rx.clone();
|
||||
let result_tx = result_tx.clone();
|
||||
let save_tx = save_actor.sender();
|
||||
let cache = cache.clone();
|
||||
|
||||
std::thread::spawn(move || {
|
||||
// Block until a job arrives.
|
||||
while let Ok(pos) = job_rx.recv() {
|
||||
let chunk = load_chunk(&generator, &save_tx, &cache, pos);
|
||||
// A send error means the main thread has gone away; nothing is left to do but let the worker wind down.
|
||||
if result_tx.send((pos, chunk)).is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Drop the template ends left over after cloning so the channels close once the real holders are gone.
|
||||
drop(job_rx);
|
||||
drop(result_tx);
|
||||
|
||||
let save_tx = save_actor.sender();
|
||||
|
||||
Self {
|
||||
chunks: HashMap::new(),
|
||||
job_tx,
|
||||
result_rx,
|
||||
in_flight: HashSet::new(),
|
||||
save_actor,
|
||||
workers,
|
||||
generator,
|
||||
cache,
|
||||
save_tx,
|
||||
}
|
||||
}
|
||||
|
||||
/// Number of chunks currently resident in memory.
|
||||
#[must_use]
|
||||
pub fn loaded_count(&self) -> usize {
|
||||
self.chunks.len()
|
||||
}
|
||||
|
||||
/// Returns the resident chunk at `pos`, or `None` if it is not currently loaded.
|
||||
#[must_use]
|
||||
pub fn chunk(&self, pos: ChunkPos) -> Option<&Chunk> {
|
||||
self.chunks.get(&pos)
|
||||
}
|
||||
|
||||
/// Number of chunks dispatched to the worker pool but not yet returned.
|
||||
#[must_use]
|
||||
pub fn in_flight_count(&self) -> usize {
|
||||
self.in_flight.len()
|
||||
}
|
||||
|
||||
/// Returns `true` when the worker pool has no outstanding work, i.e. every dispatched chunk has been returned. The loading gate polls this to decide when the initial region has finished streaming.
|
||||
#[must_use]
|
||||
pub fn streaming_idle(&self) -> bool {
|
||||
self.in_flight.is_empty()
|
||||
}
|
||||
|
||||
/// Reconciles resident chunks against the desired set without blocking the caller: finished chunks are drained from the worker pool, resident chunks absent from `desired` are evicted, and still-missing chunks are dispatched to the pool.
|
||||
pub fn reconcile(&mut self, desired: &HashSet<ChunkPos>) -> StreamStats {
|
||||
// Drain: absorb every chunk the workers finished since the last pass.
|
||||
let mut loaded = 0;
|
||||
while let Ok((pos, chunk)) = self.result_rx.try_recv() {
|
||||
self.in_flight.remove(&pos);
|
||||
// A finished chunk is only kept if it is still wanted.
|
||||
if desired.contains(&pos) {
|
||||
self.chunks.insert(pos, chunk);
|
||||
loaded += 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Evict: drop resident chunks no anchor wants any more.
|
||||
let stale: Vec<ChunkPos> = self
|
||||
.chunks
|
||||
.keys()
|
||||
.filter(|pos| !desired.contains(pos))
|
||||
.copied()
|
||||
.collect();
|
||||
for pos in &stale {
|
||||
// The chunk is taken by value so it can be diffed against its baseline before being dropped.
|
||||
let Some(chunk) = self.chunks.remove(pos) else {
|
||||
continue;
|
||||
};
|
||||
let baseline = self.cache.get_or_generate(*pos, &self.generator);
|
||||
// * NOTE: The diff is stamped with worldgen version 0: a single version exists today. This must become the chunk's stored version once worldgen versioning lands.
|
||||
let data = ChunkData::from_diff(*pos, 0, &baseline, &chunk);
|
||||
// A clean chunk drops any prior record into the region free list; a dirty chunk writes its diff back. Both only mutate the actor's in-memory image until a flush. A send error means the actor is gone, which the tick thread cannot act on.
|
||||
let request = if data.is_unmodified() {
|
||||
SaveRequest::Remove { pos: *pos }
|
||||
} else {
|
||||
SaveRequest::Write { pos: *pos, data }
|
||||
};
|
||||
let _ = self.save_tx.send(request);
|
||||
}
|
||||
|
||||
// Dispatch: request a load for every wanted position that is neither resident nor already in flight.
|
||||
for &pos in desired {
|
||||
if !self.chunks.contains_key(&pos) && !self.in_flight.contains(&pos) {
|
||||
self.in_flight.insert(pos);
|
||||
// A send error means the workers shut down; nothing useful can be done with the position, so the failure is ignored.
|
||||
let _ = self.job_tx.send(pos);
|
||||
}
|
||||
}
|
||||
|
||||
StreamStats {
|
||||
loaded,
|
||||
unloaded: stale.len(),
|
||||
resident: self.chunks.len(),
|
||||
in_flight: self.in_flight.len(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolves a chunk position to dense voxel data: a saved modification is applied over its baseline, otherwise the deterministic baseline is regenerated directly.
|
||||
fn load_chunk(
|
||||
generator: &VoxelGenerator,
|
||||
save_tx: &Sender<SaveRequest>,
|
||||
cache: &ChunkCache,
|
||||
pos: ChunkPos,
|
||||
) -> Chunk {
|
||||
match request_saved_chunk(save_tx, pos) {
|
||||
Ok(Some(data)) => {
|
||||
// A saved modification stores only edits, so the baseline is regenerated and the edits are layered on top.
|
||||
// TODO: once worldgen versioning exists, the baseline must be regenerated at `data.worldgen_version()` rather than the current version; today there is a single version, so the current baseline matches.
|
||||
data.materialize(&cache.get_or_generate(pos, generator))
|
||||
}
|
||||
// The chunk was never modified, so its content is exactly the deterministic baseline.
|
||||
Ok(None) => cache.get_or_generate(pos, generator),
|
||||
Err(error) => {
|
||||
// A save-layer failure must not wedge streaming; the chunk falls back to a fresh baseline and the error is logged.
|
||||
warn!(?error, ?pos, "chunk load failed; regenerating baseline");
|
||||
cache.get_or_generate(pos, generator)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Sends a read request to the save actor and blocks for its reply, mapping a departed actor to an absent record so generation can still proceed.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns the [`SaveError`] reported by the save actor if reading the stored chunk fails. A departed actor yields `Ok(None)` rather than an error.
|
||||
fn request_saved_chunk(
|
||||
save_tx: &Sender<SaveRequest>,
|
||||
pos: ChunkPos,
|
||||
) -> Result<Option<ChunkData>, SaveError> {
|
||||
let (reply_tx, reply_rx) = crossbeam_channel::bounded(1);
|
||||
if save_tx
|
||||
.send(SaveRequest::Read {
|
||||
pos,
|
||||
reply: reply_tx,
|
||||
})
|
||||
.is_err()
|
||||
{
|
||||
return Ok(None);
|
||||
}
|
||||
// A receive error means the actor dropped the reply end, treated the same as no saved data.
|
||||
reply_rx.recv().unwrap_or(Ok(None))
|
||||
}
|
||||
|
||||
/// Inserts every chunk position inside the streaming cylinder around `center` into `out`.
|
||||
pub fn cylinder_chunks<S: std::hash::BuildHasher>(
|
||||
center: ChunkPos,
|
||||
radius: i32,
|
||||
out: &mut HashSet<ChunkPos, S>,
|
||||
) {
|
||||
for x in center.x - radius..=center.x + radius {
|
||||
for z in center.z - radius..=center.z + radius {
|
||||
let dx = x - center.x;
|
||||
let dz = z - center.z;
|
||||
|
||||
// Keep only the columns whose XZ distance falls within the disc.
|
||||
if dx * dx + dz * dz <= radius * radius {
|
||||
for y in center.y - radius / 2..=center.y + radius / 2 {
|
||||
out.insert(ChunkPos::new(x, y, z));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tests/world_server.rs"]
|
||||
mod tests;
|
||||
19
crates/shared/Cargo.toml
Normal file
19
crates/shared/Cargo.toml
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
[package]
|
||||
name = "shared"
|
||||
license.workspace = true
|
||||
authors.workspace = true
|
||||
edition.workspace = true
|
||||
version.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
bytemuck.workspace = true
|
||||
fastrand = "2.4.1"
|
||||
glam.workspace = true
|
||||
noise = "0.9"
|
||||
postcard.workspace = true
|
||||
serde.workspace = true
|
||||
thiserror.workspace = true
|
||||
zstd = "0.13.3"
|
||||
87
crates/shared/src/generator.rs
Normal file
87
crates/shared/src/generator.rs
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
//! Procedural generation logic for the voxel world.
|
||||
|
||||
use noise::{NoiseFn, Perlin};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::world::{BlockId, CHUNK_SIZE, Chunk, ChunkPos};
|
||||
|
||||
/// Configuration parameters for deterministic world generation.
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
pub struct WorldGenConfig {
|
||||
/// The base height around which terrain features are generated.
|
||||
pub base_height: u32,
|
||||
/// The scale factor applied to the noise coordinates. Smaller values create wider features.
|
||||
pub noise_scale: f64,
|
||||
/// The block identifier used for the top layer of the terrain.
|
||||
pub surface_block: BlockId,
|
||||
/// The block identifier used for the layers immediately below the surface.
|
||||
pub subsurface_block: BlockId,
|
||||
/// The block identifier used for deep underground layers.
|
||||
pub stone_block: BlockId,
|
||||
}
|
||||
|
||||
/// A deterministic terrain generator that produces voxel chunks.
|
||||
pub struct VoxelGenerator {
|
||||
/// The configuration parameters guiding the generation.
|
||||
pub config: WorldGenConfig,
|
||||
noise: Perlin,
|
||||
}
|
||||
|
||||
impl VoxelGenerator {
|
||||
/// Initializes a new voxel generator with the specified configuration and seed.
|
||||
#[must_use]
|
||||
pub fn new(config: WorldGenConfig, seed: u32) -> Self {
|
||||
Self {
|
||||
config,
|
||||
noise: Perlin::new(seed),
|
||||
}
|
||||
}
|
||||
|
||||
/// Generates a complete voxel chunk for the specified position.
|
||||
#[must_use]
|
||||
#[expect(
|
||||
clippy::cast_possible_wrap,
|
||||
clippy::cast_possible_truncation,
|
||||
reason = "chunk and voxel coordinates stay within the ranges these casts assume"
|
||||
)]
|
||||
pub fn generate_chunk(&self, pos: ChunkPos) -> Chunk {
|
||||
let mut chunk = Chunk::default();
|
||||
|
||||
for x in 0..CHUNK_SIZE {
|
||||
for z in 0..CHUNK_SIZE {
|
||||
let global_x = (pos.x * CHUNK_SIZE as i32) + x as i32;
|
||||
let global_z = (pos.z * CHUNK_SIZE as i32) + z as i32;
|
||||
|
||||
let noise_val = self.noise.get([
|
||||
f64::from(global_x) * self.config.noise_scale,
|
||||
f64::from(global_z) * self.config.noise_scale,
|
||||
]);
|
||||
|
||||
let amplitude = 15.0;
|
||||
|
||||
let target_height = self.config.base_height as i32 + (noise_val * amplitude) as i32;
|
||||
|
||||
for y in 0..CHUNK_SIZE {
|
||||
let global_y = (pos.y * CHUNK_SIZE as i32) + y as i32;
|
||||
|
||||
let block = if global_y > target_height {
|
||||
BlockId::AIR
|
||||
} else if global_y == target_height {
|
||||
self.config.surface_block
|
||||
} else if global_y > target_height - 3 {
|
||||
self.config.subsurface_block
|
||||
} else {
|
||||
self.config.stone_block
|
||||
};
|
||||
|
||||
if block != BlockId::AIR {
|
||||
chunk.set(x, y, z, block);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
chunk
|
||||
}
|
||||
}
|
||||
11
crates/shared/src/lib.rs
Normal file
11
crates/shared/src/lib.rs
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
//! Shared types and logic for Synvael.
|
||||
//!
|
||||
//! This crate contains data structures and constants that are used by both the client and the server.
|
||||
|
||||
pub mod generator;
|
||||
pub mod protocol;
|
||||
pub mod save;
|
||||
pub mod session;
|
||||
pub mod world;
|
||||
14
crates/shared/src/protocol.rs
Normal file
14
crates/shared/src/protocol.rs
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
//! Network protocol types and constants.
|
||||
//!
|
||||
//! The module is split by stream purpose: [`control`]-stream handshake and disconnect messages, and the [`chunk`]-sync request/delivery messages, and the periodic [`authority`]-stream state. Control-stream types are re-exported here so callers continue to refer to `shared::protocol::<Type>` regardless of the internal layout, while the chunk types stay namespaced under `shared::protocol::chunk` to keep the two protocols visually distinct.
|
||||
|
||||
pub mod authority;
|
||||
pub mod chunk;
|
||||
mod control;
|
||||
|
||||
pub use control::{
|
||||
ClientHello, ControlMessage, Disconnect, FeatureFlags, HandshakeAck, HandshakeReject,
|
||||
PROTOCOL_VERSION, PackRef, PackTier, PlayerIdentity, RejectReason, RequiredPack, StreamLayout,
|
||||
};
|
||||
45
crates/shared/src/protocol/authority.rs
Normal file
45
crates/shared/src/protocol/authority.rs
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
//! Authority-stream messages: periodic state pushed from server to client.
|
||||
//!
|
||||
//! The authority stream ([`StreamLayout::authority`](super::StreamLayout::authority), id 2) carries state the server is the sole authority over and pushes without being asked. Diagnostics are the first such payload; simulation snapshots will join them on the same stream.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Messages carried on the authority stream (stream 2): periodic server-authoritative state.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub enum AuthorityMessage {
|
||||
/// Periodic simulation diagnostics, emitted on a fixed wall-clock cadence rather than per tick.
|
||||
ServerStats(ServerStats),
|
||||
}
|
||||
|
||||
/// A snapshot of the server's simulation health, sent roughly once per second.
|
||||
///
|
||||
/// The measured figures exist because the nominal tick rate advertised in [`HandshakeAck::tick_rate_hint`](super::HandshakeAck::tick_rate_hint) is a constant: it states what the server intends to run at and can never reveal that it is falling behind. Everything here is observed.
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Default)]
|
||||
pub struct ServerStats {
|
||||
/// Ticks actually completed in the reporting window, expressed per second. Below the nominal rate when the server is overrunning its budget.
|
||||
pub measured_tps: f32,
|
||||
/// Mean wall-clock duration of a tick body in the reporting window, in milliseconds, excluding the sleep that pads the tick out to its period.
|
||||
pub mean_tick_ms: f32,
|
||||
/// Longest tick body observed in the reporting window, in milliseconds. A mean within budget alongside a spiking maximum indicates intermittent stalls rather than sustained overload.
|
||||
pub max_tick_ms: f32,
|
||||
/// Share of the nominal tick period consumed by the mean tick body, in percent. Values at or above 100 mean the server no longer has headroom.
|
||||
pub tick_budget_percent: f32,
|
||||
/// Chunks resident in the server's world cache.
|
||||
pub loaded_chunks: u32,
|
||||
/// Chunk generation jobs outstanding in the server's worker pool.
|
||||
pub chunks_in_flight: u32,
|
||||
/// Clients with an established session.
|
||||
pub connected_clients: u32,
|
||||
/// Non-player entities occupying the world.
|
||||
pub entities: u32,
|
||||
/// Players currently in the world.
|
||||
pub players: u32,
|
||||
/// Wall-clock time since the simulation loop started, in seconds.
|
||||
pub uptime_secs: u64,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "../tests/protocol_authority.rs"]
|
||||
mod tests;
|
||||
38
crates/shared/src/protocol/chunk.rs
Normal file
38
crates/shared/src/protocol/chunk.rs
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
//! Chunk-sync message types carried between server and client.
|
||||
|
||||
use crate::world::{ChunkData, ChunkPos};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// A client's request for the chunks it wants resident, expressed as a center and radius.
|
||||
// TODO: per-chunk request/ack + flow control for the fuller protocol.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ChunkSubscribe {
|
||||
/// Chunk-space center the client wants chunks around (derived from its camera/player).
|
||||
pub center: ChunkPos,
|
||||
/// Load radius in chunks. The server clamps this to a server-side maximum.
|
||||
pub radius: u16,
|
||||
}
|
||||
|
||||
/// One chunk delivered to the client.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub enum ChunkMessage {
|
||||
/// Full chunk payload at a position.
|
||||
// TODO: LOD tier selection so a chunk can be delivered at a coarser stream tier.
|
||||
Chunk {
|
||||
/// Position of the delivered chunk.
|
||||
pos: ChunkPos,
|
||||
/// Serializable chunk contents (reuses the save/diff representation).
|
||||
data: ChunkData,
|
||||
},
|
||||
/// The server has dropped this chunk from the client's set; the client should unload it. This is the server-authoritative counterpart to the client's own radius-based unload: the server can force a discard even when the chunk is still within the client's radius.
|
||||
Drop {
|
||||
/// Position the client should discard.
|
||||
pos: ChunkPos,
|
||||
},
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "../tests/protocol_chunk.rs"]
|
||||
mod tests;
|
||||
200
crates/shared/src/protocol/control.rs
Normal file
200
crates/shared/src/protocol/control.rs
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
//! Control-stream messages: handshake negotiation and orderly disconnect.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Wire-protocol version. Incremented on any breaking change to the message layout below.
|
||||
pub const PROTOCOL_VERSION: u32 = 2;
|
||||
|
||||
/// Messages carried on the control stream (stream 0): handshake and disconnect.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub enum ControlMessage {
|
||||
/// First message a client sends after the QUIC/TLS handshake.
|
||||
ClientHello(ClientHello),
|
||||
/// Server acceptance carrying negotiated session parameters.
|
||||
HandshakeAck(HandshakeAck),
|
||||
/// Server refusal with a machine-readable reason.
|
||||
HandshakeReject(HandshakeReject),
|
||||
/// Orderly session teardown initiated by either side.
|
||||
Disconnect(Disconnect),
|
||||
}
|
||||
|
||||
/// First message a client sends after the QUIC/TLS handshake.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct ClientHello {
|
||||
/// Protocol version the client was built against; compared to `PROTOCOL_VERSION`.
|
||||
pub protocol_version: u32,
|
||||
/// Human-readable client build string (e.g. crate version + git hash).
|
||||
pub client_build: String,
|
||||
/// Identity the player presents. Minimal for M1.
|
||||
pub player_identity: PlayerIdentity,
|
||||
/// Content packs the client has installed. Empty in M1; validated later.
|
||||
pub installed_packs: Vec<PackRef>,
|
||||
/// Optional protocol feature bits the client requests. Zero in M1.
|
||||
pub requested_features: FeatureFlags,
|
||||
}
|
||||
|
||||
/// Server acceptance carrying negotiated session parameters.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct HandshakeAck {
|
||||
/// Server's protocol version (equal to the client's on success).
|
||||
pub protocol_version: u32,
|
||||
/// Human-readable server build string.
|
||||
pub server_build: String,
|
||||
/// Packs the world requires, each with an optional download source. May include `PackTier::Resource` entries (a server resource pack), which are delivered one-way and applied client-side rather than strict-matched; a consumer must branch on tier (or `PackTier::requires_strict_match`) before treating an entry as a match requirement.
|
||||
pub world_packs: Vec<RequiredPack>,
|
||||
/// Packs the client is missing relative to the server, each with an optional download source. As with `world_packs`, `PackTier::Resource` entries are delivered, not matched.
|
||||
pub missing_packs: Vec<RequiredPack>,
|
||||
/// Which stream carries which purpose for this session.
|
||||
pub stream_layout: StreamLayout,
|
||||
/// Advisory server tick rate in Hz, for client clock setup.
|
||||
pub tick_rate_hint: u16,
|
||||
}
|
||||
|
||||
/// Server refusal with a machine-readable reason.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct HandshakeReject {
|
||||
/// Machine-readable rejection category.
|
||||
pub reason: RejectReason,
|
||||
/// Human-readable detail for logs and UI.
|
||||
pub detail: String,
|
||||
/// Optional URL directing the user to a compatible build or pack, when the rejection is recoverable (e.g. `ProtocolMismatch`, `PackMismatch`).
|
||||
pub upgrade_url: Option<String>,
|
||||
}
|
||||
|
||||
/// Orderly session teardown initiated by either side.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct Disconnect {
|
||||
/// Human-readable reason shown to the peer and logged.
|
||||
pub reason: String,
|
||||
}
|
||||
|
||||
/// Machine-readable categories for handshake rejection.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum RejectReason {
|
||||
/// Client protocol version does not match the server's.
|
||||
ProtocolMismatch,
|
||||
/// Client is missing required packs or has incompatible versions.
|
||||
PackMismatch,
|
||||
/// Client declined or failed to fetch a server resource pack the server marked required.
|
||||
ResourcePackDeclined,
|
||||
/// Client failed to authenticate.
|
||||
AuthFailed,
|
||||
/// Client is banned from the server.
|
||||
Banned,
|
||||
/// Server is full.
|
||||
Full,
|
||||
/// Server encountered an internal error during handshake.
|
||||
ServerError,
|
||||
}
|
||||
|
||||
/// Identity presented by the player to the server.
|
||||
// TODO: use authenticated identity once the Account system exists.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct PlayerIdentity {
|
||||
/// Human-readable display name.
|
||||
pub display_name: String,
|
||||
}
|
||||
|
||||
/// Reference to a content pack (resource pack, data pack, or Lua mod) as it appears in a modlist exchanged during the handshake.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct PackRef {
|
||||
/// Namespaced content identifier of the pack (`namespace:id`). Charset validation is deferred to the modlist-matching concept (out of M1 scope).
|
||||
pub id: String,
|
||||
/// Human-readable semantic version. Informational only; not the match key.
|
||||
pub version: String,
|
||||
/// Canonical hash of the pack contents; the authoritative match key.
|
||||
// TODO: pin the canonical hashing procedure (traversal order, newline normalization) so independent builds of one pack hash identically.
|
||||
pub content_hash: [u8; 32],
|
||||
/// Tier the pack was classified into, which governs whether a client/server mismatch on this pack is fatal or tolerated. Inferred by the owner from the pack's folder contents (see Load order), never self-declared.
|
||||
pub tier: PackTier,
|
||||
}
|
||||
|
||||
/// Classification of a content pack, determining the handshake matching rule applied to it. Inferred from folder contents, not self-declared: `assets/`-only is a resource pack, `data/`-only is a data pack, presence of `scripts/` is a Lua mod.
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub enum PackTier {
|
||||
/// Client-side asset overlay (`assets/` only). Never strict-matched between peers. A server may push one server resource pack of its own, delivered one-way and applied on top of the client's local pack stack; enforcement of a `required` server pack is apply-or-reject at the client, not a peer hash-match.
|
||||
Resource,
|
||||
/// Declarative content (`data/` only). Must match exactly between peers.
|
||||
Data,
|
||||
/// Lua mod (`scripts/`, optionally `data/` and `assets/`); full API access.
|
||||
Mod {
|
||||
/// Set when the mod ships no `data/` and every system is `scope = "client"`, so a client/server mismatch on it cannot desync authoritative state and is therefore tolerated. Not trusted blindly by the server for packs carrying data or server-scoped systems.
|
||||
client_only: bool,
|
||||
},
|
||||
}
|
||||
|
||||
impl PackTier {
|
||||
/// Returns whether a pack of this tier must match byte-for-byte between client and server for the connection to be accepted. Resource packs are never matched; data packs and non-`client_only` mods must match exactly. A `false` here does not imply the server never sends the pack, a server resource pack is delivered one-way despite not being part of bidirectional matching.
|
||||
#[must_use]
|
||||
pub fn requires_strict_match(self) -> bool {
|
||||
match self {
|
||||
PackTier::Resource => false,
|
||||
PackTier::Data => true,
|
||||
PackTier::Mod { client_only } => !client_only,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A pack the server's world requires, paired with an optional out-of-band download source. Sent server → client in the handshake; the client fetches any it lacks via the URL when present, otherwise over the QUIC asset stream.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct RequiredPack {
|
||||
/// Identity and tier of the required pack.
|
||||
pub pack: PackRef,
|
||||
/// Optional HTTP(S) URL to fetch the pack from, bypassing the QUIC asset stream for large downloads. `None` means fetch over the asset stream.
|
||||
pub download_url: Option<String>,
|
||||
/// Whether the connection is rejected if the client cannot obtain and apply this pack. For data/mod tiers this is always `true` (they are mandatory for a correct session). For a `PackTier::Resource` entry (a server resource pack) it distinguishes an *optional* overlay the client may decline and keep playing (`false`) from a *required* one whose decline or fetch failure rejects the connection (`true`).
|
||||
pub required: bool,
|
||||
}
|
||||
|
||||
/// Optional protocol feature bits.
|
||||
#[repr(transparent)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
|
||||
pub struct FeatureFlags(pub u32);
|
||||
|
||||
/// Mapping of logical purposes to QUIC stream IDs.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct StreamLayout {
|
||||
/// Stream ID for control messages (handshake, disconnect).
|
||||
pub control: u8,
|
||||
/// Stream ID for client input to server.
|
||||
pub input: u8,
|
||||
/// Stream ID for server authoritative state updates.
|
||||
pub authority: u8,
|
||||
/// Stream ID for highest detail chunk updates (LOD0).
|
||||
pub chunk_lod0: u8,
|
||||
/// Stream ID for chunk updates (LOD1).
|
||||
pub chunk_lod1: u8,
|
||||
/// Stream ID for chunk updates (LOD2).
|
||||
pub chunk_lod2: u8,
|
||||
/// Stream ID for chunk updates (LOD3).
|
||||
pub chunk_lod3: u8,
|
||||
/// Stream ID for lowest detail chunk updates (LOD4).
|
||||
pub chunk_lod4: u8,
|
||||
/// Stream ID for downloading assets.
|
||||
pub asset: u8,
|
||||
/// Stream ID for downloading mod scripts.
|
||||
pub mod_data: u8,
|
||||
}
|
||||
|
||||
impl Default for StreamLayout {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
control: 0,
|
||||
input: 1,
|
||||
authority: 2,
|
||||
chunk_lod0: 3,
|
||||
chunk_lod1: 4,
|
||||
chunk_lod2: 5,
|
||||
chunk_lod3: 6,
|
||||
chunk_lod4: 7,
|
||||
asset: 8,
|
||||
mod_data: 9,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "../tests/protocol.rs"]
|
||||
mod tests;
|
||||
12
crates/shared/src/save.rs
Normal file
12
crates/shared/src/save.rs
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
//! On-disk save format: the framing that persists modified chunks to region files.
|
||||
//!
|
||||
//! The format is built bottom-up. The smallest unit is the `SYNC` per-chunk [`record`], which wraps one [`crate::world::ChunkData`] in a self-describing, compressed frame. Region-level framing (the `SYNR` file and its header table) is layered on top of it. All parsing treats on-disk bytes as untrusted and reports failures through [`SaveError`].
|
||||
|
||||
mod cursor;
|
||||
mod error;
|
||||
pub mod record;
|
||||
pub mod region;
|
||||
|
||||
pub use error::SaveError;
|
||||
53
crates/shared/src/save/cursor.rs
Normal file
53
crates/shared/src/save/cursor.rs
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
//! A bounds-checked, forward-only cursor over untrusted save-format bytes.
|
||||
|
||||
use super::error::SaveError;
|
||||
|
||||
/// A forward-only reader over a byte slice that bounds-checks every read.
|
||||
pub(crate) struct Reader<'a> {
|
||||
/// The full buffer being read.
|
||||
bytes: &'a [u8],
|
||||
/// The offset of the next unread byte.
|
||||
offset: usize,
|
||||
}
|
||||
|
||||
impl<'a> Reader<'a> {
|
||||
pub(crate) fn new(bytes: &'a [u8]) -> Self {
|
||||
Self { bytes, offset: 0 }
|
||||
}
|
||||
|
||||
/// Returns the next `n` bytes and advances the cursor.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`SaveError::Truncated`] if fewer than `n` bytes remain, or if the offset addition overflows.
|
||||
pub(crate) fn take(&mut self, n: usize) -> Result<&'a [u8], SaveError> {
|
||||
let end = self.offset.checked_add(n).ok_or(SaveError::Truncated {
|
||||
offset: self.offset,
|
||||
needed: n,
|
||||
available: self.bytes.len().saturating_sub(self.offset),
|
||||
})?;
|
||||
let slice = self
|
||||
.bytes
|
||||
.get(self.offset..end)
|
||||
.ok_or(SaveError::Truncated {
|
||||
offset: self.offset,
|
||||
needed: n,
|
||||
available: self.bytes.len().saturating_sub(self.offset),
|
||||
})?;
|
||||
self.offset = end;
|
||||
Ok(slice)
|
||||
}
|
||||
|
||||
/// Returns the next `N` bytes as a fixed-size array and advances the cursor.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`SaveError::Truncated`] if fewer than `N` bytes remain.
|
||||
pub(crate) fn take_array<const N: usize>(&mut self) -> Result<[u8; N], SaveError> {
|
||||
let mut array = [0u8; N];
|
||||
array.copy_from_slice(self.take(N)?);
|
||||
Ok(array)
|
||||
}
|
||||
}
|
||||
62
crates/shared/src/save/error.rs
Normal file
62
crates/shared/src/save/error.rs
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
//! Error types for reading and writing the on-disk save format.
|
||||
|
||||
use thiserror::Error;
|
||||
|
||||
/// A failure encountered while encoding or decoding a save-format record.
|
||||
#[derive(Debug, Error)]
|
||||
pub enum SaveError {
|
||||
/// The leading magic bytes did not match the expected record tag.
|
||||
#[error("record magic mismatch: expected {expected:?}, found {found:?}")]
|
||||
BadMagic {
|
||||
/// The magic the record was expected to begin with.
|
||||
expected: [u8; 4],
|
||||
/// The magic actually found at the start of the buffer.
|
||||
found: [u8; 4],
|
||||
},
|
||||
|
||||
/// The buffer ended before a field of the declared size could be read.
|
||||
#[error("record truncated at offset {offset}: needed {needed} bytes, {available} available")]
|
||||
Truncated {
|
||||
/// The byte offset at which the read was attempted.
|
||||
offset: usize,
|
||||
/// The number of bytes the field required.
|
||||
needed: usize,
|
||||
/// The number of bytes actually remaining from `offset`.
|
||||
available: usize,
|
||||
},
|
||||
|
||||
/// The decompressed payload length did not match the length the header declared.
|
||||
#[error("payload length mismatch: header declared {expected} bytes, decompressed {actual}")]
|
||||
LengthMismatch {
|
||||
/// The uncompressed length recorded in the header.
|
||||
expected: usize,
|
||||
/// The length actually produced by decompression.
|
||||
actual: usize,
|
||||
},
|
||||
|
||||
/// The payload was too large for its length to fit the 32-bit header field.
|
||||
#[error("payload too large to frame: {len} bytes exceeds the u32 length field")]
|
||||
PayloadTooLarge {
|
||||
/// The oversized payload length in bytes.
|
||||
len: usize,
|
||||
},
|
||||
|
||||
/// The record or region declared a format version this build does not support.
|
||||
#[error("unsupported format version {found}, expected {expected}")]
|
||||
UnsupportedVersion {
|
||||
/// The format version this build writes and can read.
|
||||
expected: u32,
|
||||
/// The format version actually found in the header.
|
||||
found: u32,
|
||||
},
|
||||
|
||||
/// The payload could not be (de)serialized by `postcard`.
|
||||
#[error("payload serialization failed")]
|
||||
Serialization(#[from] postcard::Error),
|
||||
|
||||
/// Compression or decompression failed at the I/O layer (`zstd`).
|
||||
#[error("payload compression failed")]
|
||||
Compression(#[from] std::io::Error),
|
||||
}
|
||||
110
crates/shared/src/save/record.rs
Normal file
110
crates/shared/src/save/record.rs
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
//! The `SYNC` per-chunk record: the on-disk framing around one [`ChunkData`].
|
||||
//!
|
||||
//! A record is a fixed-size header followed by a zstd-compressed, postcard-serialized [`ChunkData`] payload. All multi-byte integers are little-endian. The framing itself is never compressed, so a repair tool can read the header without decompressing.
|
||||
|
||||
use super::cursor::Reader;
|
||||
use super::error::SaveError;
|
||||
use crate::world::ChunkData;
|
||||
|
||||
/// The magic tag every chunk record begins with.
|
||||
const MAGIC: [u8; 4] = *b"SYNC";
|
||||
|
||||
/// The current schema version of the `ChunkData` payload, written into every new record.
|
||||
pub const CHUNK_FORMAT_VERSION: u16 = 1;
|
||||
|
||||
/// The zstd compression level used for chunk payloads: level 3 favours speed, per the save-format design.
|
||||
const ZSTD_LEVEL: i32 = 3;
|
||||
|
||||
/// The size in bytes of the fixed record header: magic + version + flags + timestamp + two length fields.
|
||||
const HEADER_LEN: usize = 4 + 2 + 2 + 8 + 4 + 4;
|
||||
|
||||
/// The non-payload header fields of a decoded record.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct RecordMeta {
|
||||
/// The schema version the payload was written under, used to drive per-chunk migration on load.
|
||||
pub chunk_format_version: u16,
|
||||
/// Reserved record flags; currently always zero.
|
||||
pub flags: u16,
|
||||
/// The wall-clock time the chunk was last modified, in milliseconds since the Unix epoch.
|
||||
pub last_modified: u64,
|
||||
}
|
||||
|
||||
/// Encodes `data` into a `SYNC` record, stamping it with `last_modified` (unix-ms).
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`SaveError::Postcard`] if serialization fails, [`SaveError::Io`] if zstd compression fails, or [`SaveError::PayloadTooLarge`] if either the uncompressed or compressed length exceeds `u32::MAX`.
|
||||
pub fn encode(data: &ChunkData, last_modified: u64) -> Result<Vec<u8>, SaveError> {
|
||||
let uncompressed = postcard::to_stdvec(data)?;
|
||||
let compressed = zstd::encode_all(uncompressed.as_slice(), ZSTD_LEVEL)?;
|
||||
|
||||
// Checked conversion so an oversized payload fails loudly instead of truncating.
|
||||
let uncompressed_len =
|
||||
u32::try_from(uncompressed.len()).map_err(|_| SaveError::PayloadTooLarge {
|
||||
len: uncompressed.len(),
|
||||
})?;
|
||||
let compressed_len =
|
||||
u32::try_from(compressed.len()).map_err(|_| SaveError::PayloadTooLarge {
|
||||
len: compressed.len(),
|
||||
})?;
|
||||
|
||||
let mut out = Vec::with_capacity(HEADER_LEN + compressed.len());
|
||||
out.extend_from_slice(&MAGIC);
|
||||
out.extend_from_slice(&CHUNK_FORMAT_VERSION.to_le_bytes());
|
||||
out.extend_from_slice(&0u16.to_le_bytes());
|
||||
out.extend_from_slice(&last_modified.to_le_bytes());
|
||||
out.extend_from_slice(&uncompressed_len.to_le_bytes());
|
||||
out.extend_from_slice(&compressed_len.to_le_bytes());
|
||||
out.extend_from_slice(&compressed);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Decodes a `SYNC` record, returning its header metadata and the reconstructed [`ChunkData`].
|
||||
///
|
||||
/// `bytes` is untrusted on-disk input, so every field is bounds-checked and the decompressed
|
||||
/// payload length is validated against the header before deserialization is attempted.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`SaveError::Truncated`] if the buffer ends mid-field, [`SaveError::BadMagic`] if the leading tag is not `SYNC`, [`SaveError::Io`] if zstd decompression fails, [`SaveError::LengthMismatch`] if the decompressed length disagrees with the header, or [`SaveError::Postcard`] if the payload fails to deserialize.
|
||||
pub fn decode(bytes: &[u8]) -> Result<(RecordMeta, ChunkData), SaveError> {
|
||||
let mut reader = Reader::new(bytes);
|
||||
|
||||
let magic = reader.take_array::<4>()?;
|
||||
if magic != MAGIC {
|
||||
return Err(SaveError::BadMagic {
|
||||
expected: MAGIC,
|
||||
found: magic,
|
||||
});
|
||||
}
|
||||
|
||||
let chunk_format_version = u16::from_le_bytes(reader.take_array()?);
|
||||
let flags = u16::from_le_bytes(reader.take_array()?);
|
||||
let last_modified = u64::from_le_bytes(reader.take_array()?);
|
||||
// Widening u32 -> usize is lossless on every supported (64-bit) target.
|
||||
let uncompressed_len = u32::from_le_bytes(reader.take_array()?) as usize;
|
||||
let compressed_len = u32::from_le_bytes(reader.take_array()?) as usize;
|
||||
|
||||
let payload = reader.take(compressed_len)?;
|
||||
let decompressed = zstd::decode_all(payload)?;
|
||||
if decompressed.len() != uncompressed_len {
|
||||
return Err(SaveError::LengthMismatch {
|
||||
expected: uncompressed_len,
|
||||
actual: decompressed.len(),
|
||||
});
|
||||
}
|
||||
|
||||
let data: ChunkData = postcard::from_bytes(&decompressed)?;
|
||||
let meta = RecordMeta {
|
||||
chunk_format_version,
|
||||
flags,
|
||||
last_modified,
|
||||
};
|
||||
Ok((meta, data))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "../tests/record.rs"]
|
||||
mod tests;
|
||||
265
crates/shared/src/save/region.rs
Normal file
265
crates/shared/src/save/region.rs
Normal file
|
|
@ -0,0 +1,265 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
//! The `SYNR` region index: the header table and side tables that frame a region file.
|
||||
//!
|
||||
//! A region file is this fixed header and its three tables, followed by the `SYNC` chunk
|
||||
//! records the header table points at. This module owns only the index; the record bytes
|
||||
//! and their placement are managed by the durability layer. All integers are little-endian.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use super::cursor::Reader;
|
||||
use super::error::SaveError;
|
||||
use crate::world::ChunkPos;
|
||||
|
||||
/// The magic tag every region file begins with.
|
||||
const MAGIC: [u8; 4] = *b"SYNR";
|
||||
|
||||
/// The current region-file framing version, written on encode and checked on decode.
|
||||
pub const REGION_FORMAT_VERSION: u32 = 1;
|
||||
|
||||
/// The location and flags of one chunk record within the region file.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct HeaderEntry {
|
||||
/// The byte offset of the record from the start of the region file.
|
||||
pub offset: u64,
|
||||
/// The length of the record in bytes.
|
||||
pub length: u32,
|
||||
/// Record flags; currently always zero.
|
||||
pub flags: u32,
|
||||
}
|
||||
|
||||
/// A reclaimable span of free space left by a removed or shrunken record.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct FreeSpan {
|
||||
/// The byte offset of the free span from the start of the region file.
|
||||
pub offset: u64,
|
||||
/// The length of the free span in bytes.
|
||||
pub length: u32,
|
||||
}
|
||||
|
||||
/// The in-memory index of a region file: where every resident chunk record lives, the free spans between them, and the worldgen-version exceptions.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct RegionIndex {
|
||||
/// Monotonic cache-coherence counter for derived LOD tiles; only ever increases.
|
||||
region_tile_version: u32,
|
||||
/// The worldgen version most chunks in the region are pinned to; the stamp table holds only exceptions.
|
||||
base_worldgen_version: u32,
|
||||
/// Location of every resident chunk record, keyed by chunk position.
|
||||
header_table: BTreeMap<ChunkPos, HeaderEntry>,
|
||||
/// Reclaimable holes in the file, in no particular order.
|
||||
free_list: Vec<FreeSpan>,
|
||||
/// Worldgen-version exceptions: chunks pinned to a version other than `base_worldgen_version`.
|
||||
stamps: BTreeMap<ChunkPos, u32>,
|
||||
}
|
||||
|
||||
impl RegionIndex {
|
||||
/// Creates an empty index whose chunks default to `base_worldgen_version`.
|
||||
#[must_use]
|
||||
pub fn new(base_worldgen_version: u32) -> Self {
|
||||
Self {
|
||||
region_tile_version: 0,
|
||||
base_worldgen_version,
|
||||
header_table: BTreeMap::new(),
|
||||
free_list: Vec::new(),
|
||||
stamps: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the monotonic LOD-tile version.
|
||||
#[must_use]
|
||||
pub fn region_tile_version(&self) -> u32 {
|
||||
self.region_tile_version
|
||||
}
|
||||
|
||||
/// Increments the LOD-tile version; called once per save that commits a rebuild-triggering change.
|
||||
pub fn bump_tile_version(&mut self) {
|
||||
self.region_tile_version = self.region_tile_version.saturating_add(1);
|
||||
}
|
||||
|
||||
/// Returns the region's base worldgen version.
|
||||
#[must_use]
|
||||
pub fn base_worldgen_version(&self) -> u32 {
|
||||
self.base_worldgen_version
|
||||
}
|
||||
|
||||
/// Looks up the record location for `pos`, if the chunk is resident.
|
||||
#[must_use]
|
||||
pub fn entry(&self, pos: ChunkPos) -> Option<&HeaderEntry> {
|
||||
self.header_table.get(&pos)
|
||||
}
|
||||
|
||||
/// Records the location of the chunk at `pos`, replacing any existing entry.
|
||||
pub fn insert(&mut self, pos: ChunkPos, entry: HeaderEntry) {
|
||||
self.header_table.insert(pos, entry);
|
||||
}
|
||||
|
||||
/// Removes the chunk at `pos` from the header table, returning its former location.
|
||||
pub fn remove(&mut self, pos: ChunkPos) -> Option<HeaderEntry> {
|
||||
self.header_table.remove(&pos)
|
||||
}
|
||||
|
||||
/// Iterates the resident records in ascending position order.
|
||||
pub fn entries(&self) -> impl Iterator<Item = (&ChunkPos, &HeaderEntry)> {
|
||||
self.header_table.iter()
|
||||
}
|
||||
|
||||
/// Returns the pinned worldgen version for `pos`: its stamp exception, or the region base.
|
||||
#[must_use]
|
||||
pub fn worldgen_version(&self, pos: ChunkPos) -> u32 {
|
||||
self.stamps
|
||||
.get(&pos)
|
||||
.copied()
|
||||
.unwrap_or(self.base_worldgen_version)
|
||||
}
|
||||
|
||||
/// Pins `pos` to `version`, recording it as a stamp exception only when it differs from the base.
|
||||
pub fn set_worldgen_version(&mut self, pos: ChunkPos, version: u32) {
|
||||
if version == self.base_worldgen_version {
|
||||
self.stamps.remove(&pos);
|
||||
} else {
|
||||
self.stamps.insert(pos, version);
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the reclaimable free spans.
|
||||
#[must_use]
|
||||
pub fn free_spans(&self) -> &[FreeSpan] {
|
||||
&self.free_list
|
||||
}
|
||||
|
||||
/// Adds a reclaimable free span.
|
||||
pub fn push_free(&mut self, span: FreeSpan) {
|
||||
self.free_list.push(span);
|
||||
}
|
||||
|
||||
/// Serializes the index to its on-disk framing bytes.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`SaveError::PayloadTooLarge`] if the header, free-list, or stamp table holds more than `u32::MAX` entries.
|
||||
pub fn encode(&self) -> Result<Vec<u8>, SaveError> {
|
||||
let mut out = Vec::new();
|
||||
out.extend_from_slice(&MAGIC);
|
||||
out.extend_from_slice(®ION_FORMAT_VERSION.to_le_bytes());
|
||||
out.extend_from_slice(&self.base_worldgen_version.to_le_bytes());
|
||||
out.extend_from_slice(&self.region_tile_version.to_le_bytes());
|
||||
|
||||
out.extend_from_slice(&len_u32(self.header_table.len())?.to_le_bytes());
|
||||
for (pos, entry) in &self.header_table {
|
||||
out.extend_from_slice(&pos.x.to_le_bytes());
|
||||
out.extend_from_slice(&pos.y.to_le_bytes());
|
||||
out.extend_from_slice(&pos.z.to_le_bytes());
|
||||
out.extend_from_slice(&entry.offset.to_le_bytes());
|
||||
out.extend_from_slice(&entry.length.to_le_bytes());
|
||||
out.extend_from_slice(&entry.flags.to_le_bytes());
|
||||
}
|
||||
|
||||
out.extend_from_slice(&len_u32(self.free_list.len())?.to_le_bytes());
|
||||
for span in &self.free_list {
|
||||
out.extend_from_slice(&span.offset.to_le_bytes());
|
||||
out.extend_from_slice(&span.length.to_le_bytes());
|
||||
}
|
||||
|
||||
// The stamp value is widened to u32 to match ChunkData's u32 worldgen version and avoid
|
||||
// truncation; the spec's u16 stamp field (Save format.md) is treated as an oversight.
|
||||
out.extend_from_slice(&len_u32(self.stamps.len())?.to_le_bytes());
|
||||
for (pos, version) in &self.stamps {
|
||||
out.extend_from_slice(&pos.x.to_le_bytes());
|
||||
out.extend_from_slice(&pos.y.to_le_bytes());
|
||||
out.extend_from_slice(&pos.z.to_le_bytes());
|
||||
out.extend_from_slice(&version.to_le_bytes());
|
||||
}
|
||||
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Parses a region index from its framing bytes, ignoring any chunk records that follow it.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`SaveError::Truncated`] if the buffer ends mid-field, [`SaveError::BadMagic`] if the leading tag is not the region magic, or [`SaveError::UnsupportedVersion`] if the format version is not recognised.
|
||||
pub fn decode(bytes: &[u8]) -> Result<Self, SaveError> {
|
||||
let mut reader = Reader::new(bytes);
|
||||
|
||||
let magic = reader.take_array::<4>()?;
|
||||
if magic != MAGIC {
|
||||
return Err(SaveError::BadMagic {
|
||||
expected: MAGIC,
|
||||
found: magic,
|
||||
});
|
||||
}
|
||||
|
||||
let format_version = u32::from_le_bytes(reader.take_array()?);
|
||||
if format_version != REGION_FORMAT_VERSION {
|
||||
return Err(SaveError::UnsupportedVersion {
|
||||
expected: REGION_FORMAT_VERSION,
|
||||
found: format_version,
|
||||
});
|
||||
}
|
||||
|
||||
let base_worldgen_version = u32::from_le_bytes(reader.take_array()?);
|
||||
let region_tile_version = u32::from_le_bytes(reader.take_array()?);
|
||||
|
||||
let header_len = u32::from_le_bytes(reader.take_array()?);
|
||||
let mut header_table = BTreeMap::new();
|
||||
for _ in 0..header_len {
|
||||
let pos = read_pos(&mut reader)?;
|
||||
let entry = HeaderEntry {
|
||||
offset: u64::from_le_bytes(reader.take_array()?),
|
||||
length: u32::from_le_bytes(reader.take_array()?),
|
||||
flags: u32::from_le_bytes(reader.take_array()?),
|
||||
};
|
||||
header_table.insert(pos, entry);
|
||||
}
|
||||
|
||||
let free_len = u32::from_le_bytes(reader.take_array()?);
|
||||
let mut free_list = Vec::with_capacity(free_len as usize);
|
||||
for _ in 0..free_len {
|
||||
free_list.push(FreeSpan {
|
||||
offset: u64::from_le_bytes(reader.take_array()?),
|
||||
length: u32::from_le_bytes(reader.take_array()?),
|
||||
});
|
||||
}
|
||||
|
||||
let stamp_len = u32::from_le_bytes(reader.take_array()?);
|
||||
let mut stamps = BTreeMap::new();
|
||||
for _ in 0..stamp_len {
|
||||
let pos = read_pos(&mut reader)?;
|
||||
stamps.insert(pos, u32::from_le_bytes(reader.take_array()?));
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
region_tile_version,
|
||||
base_worldgen_version,
|
||||
header_table,
|
||||
free_list,
|
||||
stamps,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads a chunk position as three little-endian `i32`s.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`SaveError::Truncated`] if fewer than twelve bytes remain.
|
||||
fn read_pos(reader: &mut Reader) -> Result<ChunkPos, SaveError> {
|
||||
let x = i32::from_le_bytes(reader.take_array()?);
|
||||
let y = i32::from_le_bytes(reader.take_array()?);
|
||||
let z = i32::from_le_bytes(reader.take_array()?);
|
||||
Ok(ChunkPos::new(x, y, z))
|
||||
}
|
||||
|
||||
/// Narrows a table length to the `u32` the framing uses, failing loudly rather than truncating.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`SaveError::PayloadTooLarge`] if `len` exceeds `u32::MAX`.
|
||||
fn len_u32(len: usize) -> Result<u32, SaveError> {
|
||||
u32::try_from(len).map_err(|_| SaveError::PayloadTooLarge { len })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "../tests/region.rs"]
|
||||
mod tests;
|
||||
43
crates/shared/src/session.rs
Normal file
43
crates/shared/src/session.rs
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
//! Session-level concepts describing the relationship between a client and the server it is playing against.
|
||||
|
||||
/// Which kind of server a client session is running against.
|
||||
///
|
||||
/// Deliberately not part of the wire protocol. The client already knows the answer without asking: it either spawned a server in-process or dialled a socket. A server-declared field would be redundant at best and spoofable at worst, so the value is constructed client-side from facts the client already holds.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ServerKind {
|
||||
/// The server runs in this process, backing single-player.
|
||||
Integrated,
|
||||
/// The server is a separate process reached over the network.
|
||||
Dedicated {
|
||||
/// Whether the server's address is off this machine. Decided from the `SocketAddr` the client dialled, not from anything the server says.
|
||||
remote: bool,
|
||||
},
|
||||
}
|
||||
|
||||
impl ServerKind {
|
||||
/// Classifies a dedicated server from the address the client dialled.
|
||||
///
|
||||
/// A loopback address means the process is on this machine (a locally hosted server), which is distinct from an integrated one: it is still a separate process reached over a socket.
|
||||
#[must_use]
|
||||
pub const fn dedicated(addr: std::net::SocketAddr) -> Self {
|
||||
Self::Dedicated {
|
||||
remote: !addr.ip().is_loopback(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns a short human-readable label for the session's server kind.
|
||||
#[must_use]
|
||||
pub const fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Integrated => "integrated",
|
||||
Self::Dedicated { remote: false } => "dedicated (local)",
|
||||
Self::Dedicated { remote: true } => "dedicated (remote)",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tests/session.rs"]
|
||||
mod tests;
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue