WASM plugins, scripting, i18n, GUI pan/zoom, cargo-dist CI/CD

- WASM plugin host (wasmtime 46, 17 host fns) + example noisemap plugin
- Rhai scripting engine (12 API fns, `terrafier run` cmd)
- i18n: en/ru translations, auto-detect, t! macro
- GUI: pan (click-drag) + zoom (scroll wheel) with viewport transform
- cargo-dist 0.31: MSI/pkg/shell installers, both binaries
- CI: .github/workflows/ci.yml + release.yml
- Security: wasmtime 30→46, bincode 1.3→2.0, quick-xml CVE docs
- Code review: SAFETY comments, unwrap→match cleanup
- Fixes: clippy approx_constant, needless_borrows, rustfmt
This commit is contained in:
loki5512344 2026-07-04 11:14:47 +02:00
parent bdd4564240
commit 18613260a8
100 changed files with 4262 additions and 585 deletions

64
.github/workflows/ci.yml vendored Normal file
View file

@ -0,0 +1,64 @@
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
env:
CARGO_TERM_COLOR: always
RUSTFLAGS: "-D warnings"
jobs:
check:
name: Check (${{ matrix.os }})
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt, clippy
- uses: Swatinem/rust-cache@v2
- name: Check compilation
run: cargo check --workspace --all-targets
- name: Check formatting
run: cargo fmt --all --check
- name: Clippy
run: cargo clippy --workspace --all-targets -- -D warnings
test:
name: Test (${{ matrix.os }})
runs-on: ${{ matrix.os }}
needs: check
strategy:
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- name: Run tests
run: cargo test --workspace
audit:
name: Security Audit
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- name: Install cargo-audit
run: cargo install cargo-audit --locked
- name: Audit dependencies
run: cargo audit --deny warnings

296
.github/workflows/release-old.yml vendored Normal file
View file

@ -0,0 +1,296 @@
# This file was autogenerated by dist: https://axodotdev.github.io/cargo-dist
#
# Copyright 2022-2024, axodotdev
# SPDX-License-Identifier: MIT or Apache-2.0
#
# CI that:
#
# * checks for a Git Tag that looks like a release
# * builds artifacts with dist (archives, installers, hashes)
# * uploads those artifacts to temporary workflow zip
# * on success, uploads the artifacts to a GitHub Release
#
# Note that the GitHub Release will be created with a generated
# title/body based on your changelogs.
name: Release
permissions:
"contents": "write"
# This task will run whenever you push a git tag that looks like a version
# like "1.0.0", "v0.1.0-prerelease.1", "my-app/0.1.0", "releases/v1.0.0", etc.
# Various formats will be parsed into a VERSION and an optional PACKAGE_NAME, where
# PACKAGE_NAME must be the name of a Cargo package in your workspace, and VERSION
# must be a Cargo-style SemVer Version (must have at least major.minor.patch).
#
# If PACKAGE_NAME is specified, then the announcement will be for that
# package (erroring out if it doesn't have the given version or isn't dist-able).
#
# If PACKAGE_NAME isn't specified, then the announcement will be for all
# (dist-able) packages in the workspace with that version (this mode is
# intended for workspaces with only one dist-able package, or with all dist-able
# packages versioned/released in lockstep).
#
# If you push multiple tags at once, separate instances of this workflow will
# spin up, creating an independent announcement for each one. However, GitHub
# will hard limit this to 3 tags per commit, as it will assume more tags is a
# mistake.
#
# If there's a prerelease-style suffix to the version, then the release(s)
# will be marked as a prerelease.
on:
pull_request:
push:
tags:
- '**[0-9]+.[0-9]+.[0-9]+*'
jobs:
# Run 'dist plan' (or host) to determine what tasks we need to do
plan:
runs-on: "ubuntu-22.04"
outputs:
val: ${{ steps.plan.outputs.manifest }}
tag: ${{ !github.event.pull_request && github.ref_name || '' }}
tag-flag: ${{ !github.event.pull_request && format('--tag={0}', github.ref_name) || '' }}
publishing: ${{ !github.event.pull_request }}
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
steps:
- uses: actions/checkout@v6
with:
persist-credentials: false
submodules: recursive
- name: Install dist
# we specify bash to get pipefail; it guards against the `curl` command
# failing. otherwise `sh` won't catch that `curl` returned non-0
shell: bash
run: "curl --proto '=https' --tlsv1.2 -LsSf https://github.com/axodotdev/cargo-dist/releases/download/v0.31.0/cargo-dist-installer.sh | sh"
- name: Cache dist
uses: actions/upload-artifact@v6
with:
name: cargo-dist-cache
path: ~/.cargo/bin/dist
# sure would be cool if github gave us proper conditionals...
# so here's a doubly-nested ternary-via-truthiness to try to provide the best possible
# functionality based on whether this is a pull_request, and whether it's from a fork.
# (PRs run on the *source* but secrets are usually on the *target* -- that's *good*
# but also really annoying to build CI around when it needs secrets to work right.)
- id: plan
run: |
dist ${{ (!github.event.pull_request && format('host --steps=create --tag={0}', github.ref_name)) || 'plan' }} --output-format=json > plan-dist-manifest.json
echo "dist ran successfully"
cat plan-dist-manifest.json
echo "manifest=$(jq -c "." plan-dist-manifest.json)" >> "$GITHUB_OUTPUT"
- name: "Upload dist-manifest.json"
uses: actions/upload-artifact@v6
with:
name: artifacts-plan-dist-manifest
path: plan-dist-manifest.json
# Build and packages all the platform-specific things
build-local-artifacts:
name: build-local-artifacts (${{ join(matrix.targets, ', ') }})
# Let the initial task tell us to not run (currently very blunt)
needs:
- plan
if: ${{ fromJson(needs.plan.outputs.val).ci.github.artifacts_matrix.include != null && (needs.plan.outputs.publishing == 'true' || fromJson(needs.plan.outputs.val).ci.github.pr_run_mode == 'upload') }}
strategy:
fail-fast: false
# Target platforms/runners are computed by dist in create-release.
# Each member of the matrix has the following arguments:
#
# - runner: the github runner
# - dist-args: cli flags to pass to dist
# - install-dist: expression to run to install dist on the runner
#
# Typically there will be:
# - 1 "global" task that builds universal installers
# - N "local" tasks that build each platform's binaries and platform-specific installers
matrix: ${{ fromJson(needs.plan.outputs.val).ci.github.artifacts_matrix }}
runs-on: ${{ matrix.runner }}
container: ${{ matrix.container && matrix.container.image || null }}
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
BUILD_MANIFEST_NAME: target/distrib/${{ join(matrix.targets, '-') }}-dist-manifest.json
steps:
- name: enable windows longpaths
run: |
git config --global core.longpaths true
- uses: actions/checkout@v6
with:
persist-credentials: false
submodules: recursive
- name: Install Rust non-interactively if not already installed
if: ${{ matrix.container }}
run: |
if ! command -v cargo > /dev/null 2>&1; then
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
echo "$HOME/.cargo/bin" >> $GITHUB_PATH
fi
- name: Install dist
run: ${{ matrix.install_dist.run }}
# Get the dist-manifest
- name: Fetch local artifacts
uses: actions/download-artifact@v7
with:
pattern: artifacts-*
path: target/distrib/
merge-multiple: true
- name: Install dependencies
run: |
${{ matrix.packages_install }}
- name: Build artifacts
run: |
# Actually do builds and make zips and whatnot
dist build ${{ needs.plan.outputs.tag-flag }} --print=linkage --output-format=json ${{ matrix.dist_args }} > dist-manifest.json
echo "dist ran successfully"
- id: cargo-dist
name: Post-build
# We force bash here just because github makes it really hard to get values up
# to "real" actions without writing to env-vars, and writing to env-vars has
# inconsistent syntax between shell and powershell.
shell: bash
run: |
# Parse out what we just built and upload it to scratch storage
echo "paths<<EOF" >> "$GITHUB_OUTPUT"
dist print-upload-files-from-manifest --manifest dist-manifest.json >> "$GITHUB_OUTPUT"
echo "EOF" >> "$GITHUB_OUTPUT"
cp dist-manifest.json "$BUILD_MANIFEST_NAME"
- name: "Upload artifacts"
uses: actions/upload-artifact@v6
with:
name: artifacts-build-local-${{ join(matrix.targets, '_') }}
path: |
${{ steps.cargo-dist.outputs.paths }}
${{ env.BUILD_MANIFEST_NAME }}
# Build and package all the platform-agnostic(ish) things
build-global-artifacts:
needs:
- plan
- build-local-artifacts
runs-on: "ubuntu-22.04"
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
BUILD_MANIFEST_NAME: target/distrib/global-dist-manifest.json
steps:
- uses: actions/checkout@v6
with:
persist-credentials: false
submodules: recursive
- name: Install cached dist
uses: actions/download-artifact@v7
with:
name: cargo-dist-cache
path: ~/.cargo/bin/
- run: chmod +x ~/.cargo/bin/dist
# Get all the local artifacts for the global tasks to use (for e.g. checksums)
- name: Fetch local artifacts
uses: actions/download-artifact@v7
with:
pattern: artifacts-*
path: target/distrib/
merge-multiple: true
- id: cargo-dist
shell: bash
run: |
dist build ${{ needs.plan.outputs.tag-flag }} --output-format=json "--artifacts=global" > dist-manifest.json
echo "dist ran successfully"
# Parse out what we just built and upload it to scratch storage
echo "paths<<EOF" >> "$GITHUB_OUTPUT"
jq --raw-output ".upload_files[]" dist-manifest.json >> "$GITHUB_OUTPUT"
echo "EOF" >> "$GITHUB_OUTPUT"
cp dist-manifest.json "$BUILD_MANIFEST_NAME"
- name: "Upload artifacts"
uses: actions/upload-artifact@v6
with:
name: artifacts-build-global
path: |
${{ steps.cargo-dist.outputs.paths }}
${{ env.BUILD_MANIFEST_NAME }}
# Determines if we should publish/announce
host:
needs:
- plan
- build-local-artifacts
- build-global-artifacts
# Only run if we're "publishing", and only if plan, local and global didn't fail (skipped is fine)
if: ${{ always() && needs.plan.result == 'success' && needs.plan.outputs.publishing == 'true' && (needs.build-global-artifacts.result == 'skipped' || needs.build-global-artifacts.result == 'success') && (needs.build-local-artifacts.result == 'skipped' || needs.build-local-artifacts.result == 'success') }}
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
runs-on: "ubuntu-22.04"
outputs:
val: ${{ steps.host.outputs.manifest }}
steps:
- uses: actions/checkout@v6
with:
persist-credentials: false
submodules: recursive
- name: Install cached dist
uses: actions/download-artifact@v7
with:
name: cargo-dist-cache
path: ~/.cargo/bin/
- run: chmod +x ~/.cargo/bin/dist
# Fetch artifacts from scratch-storage
- name: Fetch artifacts
uses: actions/download-artifact@v7
with:
pattern: artifacts-*
path: target/distrib/
merge-multiple: true
- id: host
shell: bash
run: |
dist host ${{ needs.plan.outputs.tag-flag }} --steps=upload --steps=release --output-format=json > dist-manifest.json
echo "artifacts uploaded and released successfully"
cat dist-manifest.json
echo "manifest=$(jq -c "." dist-manifest.json)" >> "$GITHUB_OUTPUT"
- name: "Upload dist-manifest.json"
uses: actions/upload-artifact@v6
with:
# Overwrite the previous copy
name: artifacts-dist-manifest
path: dist-manifest.json
# Create a GitHub Release while uploading all files to it
- name: "Download GitHub Artifacts"
uses: actions/download-artifact@v7
with:
pattern: artifacts-*
path: artifacts
merge-multiple: true
- name: Cleanup
run: |
# Remove the granular manifests
rm -f artifacts/*-dist-manifest.json
- name: Create GitHub Release
env:
PRERELEASE_FLAG: "${{ fromJson(steps.host.outputs.manifest).announcement_is_prerelease && '--prerelease' || '' }}"
ANNOUNCEMENT_TITLE: "${{ fromJson(steps.host.outputs.manifest).announcement_title }}"
ANNOUNCEMENT_BODY: "${{ fromJson(steps.host.outputs.manifest).announcement_github_body }}"
RELEASE_COMMIT: "${{ github.sha }}"
run: |
# Write and read notes from a file to avoid quoting breaking things
echo "$ANNOUNCEMENT_BODY" > $RUNNER_TEMP/notes.txt
gh release create "${{ needs.plan.outputs.tag }}" --target "$RELEASE_COMMIT" $PRERELEASE_FLAG --title "$ANNOUNCEMENT_TITLE" --notes-file "$RUNNER_TEMP/notes.txt" artifacts/*
announce:
needs:
- plan
- host
# use "always() && ..." to allow us to wait for all publish jobs while
# still allowing individual publish jobs to skip themselves (for prereleases).
# "host" however must run to completion, no skipping allowed!
if: ${{ always() && needs.host.result == 'success' }}
runs-on: "ubuntu-22.04"
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
steps:
- uses: actions/checkout@v6
with:
persist-credentials: false
submodules: recursive

296
.github/workflows/release.yml vendored Normal file
View file

@ -0,0 +1,296 @@
# This file was autogenerated by dist: https://axodotdev.github.io/cargo-dist
#
# Copyright 2022-2024, axodotdev
# SPDX-License-Identifier: MIT or Apache-2.0
#
# CI that:
#
# * checks for a Git Tag that looks like a release
# * builds artifacts with dist (archives, installers, hashes)
# * uploads those artifacts to temporary workflow zip
# * on success, uploads the artifacts to a GitHub Release
#
# Note that the GitHub Release will be created with a generated
# title/body based on your changelogs.
name: Release
permissions:
"contents": "write"
# This task will run whenever you push a git tag that looks like a version
# like "1.0.0", "v0.1.0-prerelease.1", "my-app/0.1.0", "releases/v1.0.0", etc.
# Various formats will be parsed into a VERSION and an optional PACKAGE_NAME, where
# PACKAGE_NAME must be the name of a Cargo package in your workspace, and VERSION
# must be a Cargo-style SemVer Version (must have at least major.minor.patch).
#
# If PACKAGE_NAME is specified, then the announcement will be for that
# package (erroring out if it doesn't have the given version or isn't dist-able).
#
# If PACKAGE_NAME isn't specified, then the announcement will be for all
# (dist-able) packages in the workspace with that version (this mode is
# intended for workspaces with only one dist-able package, or with all dist-able
# packages versioned/released in lockstep).
#
# If you push multiple tags at once, separate instances of this workflow will
# spin up, creating an independent announcement for each one. However, GitHub
# will hard limit this to 3 tags per commit, as it will assume more tags is a
# mistake.
#
# If there's a prerelease-style suffix to the version, then the release(s)
# will be marked as a prerelease.
on:
pull_request:
push:
tags:
- '**[0-9]+.[0-9]+.[0-9]+*'
jobs:
# Run 'dist plan' (or host) to determine what tasks we need to do
plan:
runs-on: "ubuntu-22.04"
outputs:
val: ${{ steps.plan.outputs.manifest }}
tag: ${{ !github.event.pull_request && github.ref_name || '' }}
tag-flag: ${{ !github.event.pull_request && format('--tag={0}', github.ref_name) || '' }}
publishing: ${{ !github.event.pull_request }}
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
steps:
- uses: actions/checkout@v6
with:
persist-credentials: false
submodules: recursive
- name: Install dist
# we specify bash to get pipefail; it guards against the `curl` command
# failing. otherwise `sh` won't catch that `curl` returned non-0
shell: bash
run: "curl --proto '=https' --tlsv1.2 -LsSf https://github.com/axodotdev/cargo-dist/releases/download/v0.31.0/cargo-dist-installer.sh | sh"
- name: Cache dist
uses: actions/upload-artifact@v6
with:
name: cargo-dist-cache
path: ~/.cargo/bin/dist
# sure would be cool if github gave us proper conditionals...
# so here's a doubly-nested ternary-via-truthiness to try to provide the best possible
# functionality based on whether this is a pull_request, and whether it's from a fork.
# (PRs run on the *source* but secrets are usually on the *target* -- that's *good*
# but also really annoying to build CI around when it needs secrets to work right.)
- id: plan
run: |
dist ${{ (!github.event.pull_request && format('host --steps=create --tag={0}', github.ref_name)) || 'plan' }} --output-format=json > plan-dist-manifest.json
echo "dist ran successfully"
cat plan-dist-manifest.json
echo "manifest=$(jq -c "." plan-dist-manifest.json)" >> "$GITHUB_OUTPUT"
- name: "Upload dist-manifest.json"
uses: actions/upload-artifact@v6
with:
name: artifacts-plan-dist-manifest
path: plan-dist-manifest.json
# Build and packages all the platform-specific things
build-local-artifacts:
name: build-local-artifacts (${{ join(matrix.targets, ', ') }})
# Let the initial task tell us to not run (currently very blunt)
needs:
- plan
if: ${{ fromJson(needs.plan.outputs.val).ci.github.artifacts_matrix.include != null && (needs.plan.outputs.publishing == 'true' || fromJson(needs.plan.outputs.val).ci.github.pr_run_mode == 'upload') }}
strategy:
fail-fast: false
# Target platforms/runners are computed by dist in create-release.
# Each member of the matrix has the following arguments:
#
# - runner: the github runner
# - dist-args: cli flags to pass to dist
# - install-dist: expression to run to install dist on the runner
#
# Typically there will be:
# - 1 "global" task that builds universal installers
# - N "local" tasks that build each platform's binaries and platform-specific installers
matrix: ${{ fromJson(needs.plan.outputs.val).ci.github.artifacts_matrix }}
runs-on: ${{ matrix.runner }}
container: ${{ matrix.container && matrix.container.image || null }}
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
BUILD_MANIFEST_NAME: target/distrib/${{ join(matrix.targets, '-') }}-dist-manifest.json
steps:
- name: enable windows longpaths
run: |
git config --global core.longpaths true
- uses: actions/checkout@v6
with:
persist-credentials: false
submodules: recursive
- name: Install Rust non-interactively if not already installed
if: ${{ matrix.container }}
run: |
if ! command -v cargo > /dev/null 2>&1; then
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
echo "$HOME/.cargo/bin" >> $GITHUB_PATH
fi
- name: Install dist
run: ${{ matrix.install_dist.run }}
# Get the dist-manifest
- name: Fetch local artifacts
uses: actions/download-artifact@v7
with:
pattern: artifacts-*
path: target/distrib/
merge-multiple: true
- name: Install dependencies
run: |
${{ matrix.packages_install }}
- name: Build artifacts
run: |
# Actually do builds and make zips and whatnot
dist build ${{ needs.plan.outputs.tag-flag }} --print=linkage --output-format=json ${{ matrix.dist_args }} > dist-manifest.json
echo "dist ran successfully"
- id: cargo-dist
name: Post-build
# We force bash here just because github makes it really hard to get values up
# to "real" actions without writing to env-vars, and writing to env-vars has
# inconsistent syntax between shell and powershell.
shell: bash
run: |
# Parse out what we just built and upload it to scratch storage
echo "paths<<EOF" >> "$GITHUB_OUTPUT"
dist print-upload-files-from-manifest --manifest dist-manifest.json >> "$GITHUB_OUTPUT"
echo "EOF" >> "$GITHUB_OUTPUT"
cp dist-manifest.json "$BUILD_MANIFEST_NAME"
- name: "Upload artifacts"
uses: actions/upload-artifact@v6
with:
name: artifacts-build-local-${{ join(matrix.targets, '_') }}
path: |
${{ steps.cargo-dist.outputs.paths }}
${{ env.BUILD_MANIFEST_NAME }}
# Build and package all the platform-agnostic(ish) things
build-global-artifacts:
needs:
- plan
- build-local-artifacts
runs-on: "ubuntu-22.04"
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
BUILD_MANIFEST_NAME: target/distrib/global-dist-manifest.json
steps:
- uses: actions/checkout@v6
with:
persist-credentials: false
submodules: recursive
- name: Install cached dist
uses: actions/download-artifact@v7
with:
name: cargo-dist-cache
path: ~/.cargo/bin/
- run: chmod +x ~/.cargo/bin/dist
# Get all the local artifacts for the global tasks to use (for e.g. checksums)
- name: Fetch local artifacts
uses: actions/download-artifact@v7
with:
pattern: artifacts-*
path: target/distrib/
merge-multiple: true
- id: cargo-dist
shell: bash
run: |
dist build ${{ needs.plan.outputs.tag-flag }} --output-format=json "--artifacts=global" > dist-manifest.json
echo "dist ran successfully"
# Parse out what we just built and upload it to scratch storage
echo "paths<<EOF" >> "$GITHUB_OUTPUT"
jq --raw-output ".upload_files[]" dist-manifest.json >> "$GITHUB_OUTPUT"
echo "EOF" >> "$GITHUB_OUTPUT"
cp dist-manifest.json "$BUILD_MANIFEST_NAME"
- name: "Upload artifacts"
uses: actions/upload-artifact@v6
with:
name: artifacts-build-global
path: |
${{ steps.cargo-dist.outputs.paths }}
${{ env.BUILD_MANIFEST_NAME }}
# Determines if we should publish/announce
host:
needs:
- plan
- build-local-artifacts
- build-global-artifacts
# Only run if we're "publishing", and only if plan, local and global didn't fail (skipped is fine)
if: ${{ always() && needs.plan.result == 'success' && needs.plan.outputs.publishing == 'true' && (needs.build-global-artifacts.result == 'skipped' || needs.build-global-artifacts.result == 'success') && (needs.build-local-artifacts.result == 'skipped' || needs.build-local-artifacts.result == 'success') }}
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
runs-on: "ubuntu-22.04"
outputs:
val: ${{ steps.host.outputs.manifest }}
steps:
- uses: actions/checkout@v6
with:
persist-credentials: false
submodules: recursive
- name: Install cached dist
uses: actions/download-artifact@v7
with:
name: cargo-dist-cache
path: ~/.cargo/bin/
- run: chmod +x ~/.cargo/bin/dist
# Fetch artifacts from scratch-storage
- name: Fetch artifacts
uses: actions/download-artifact@v7
with:
pattern: artifacts-*
path: target/distrib/
merge-multiple: true
- id: host
shell: bash
run: |
dist host ${{ needs.plan.outputs.tag-flag }} --steps=upload --steps=release --output-format=json > dist-manifest.json
echo "artifacts uploaded and released successfully"
cat dist-manifest.json
echo "manifest=$(jq -c "." dist-manifest.json)" >> "$GITHUB_OUTPUT"
- name: "Upload dist-manifest.json"
uses: actions/upload-artifact@v6
with:
# Overwrite the previous copy
name: artifacts-dist-manifest
path: dist-manifest.json
# Create a GitHub Release while uploading all files to it
- name: "Download GitHub Artifacts"
uses: actions/download-artifact@v7
with:
pattern: artifacts-*
path: artifacts
merge-multiple: true
- name: Cleanup
run: |
# Remove the granular manifests
rm -f artifacts/*-dist-manifest.json
- name: Create GitHub Release
env:
PRERELEASE_FLAG: "${{ fromJson(steps.host.outputs.manifest).announcement_is_prerelease && '--prerelease' || '' }}"
ANNOUNCEMENT_TITLE: "${{ fromJson(steps.host.outputs.manifest).announcement_title }}"
ANNOUNCEMENT_BODY: "${{ fromJson(steps.host.outputs.manifest).announcement_github_body }}"
RELEASE_COMMIT: "${{ github.sha }}"
run: |
# Write and read notes from a file to avoid quoting breaking things
echo "$ANNOUNCEMENT_BODY" > $RUNNER_TEMP/notes.txt
gh release create "${{ needs.plan.outputs.tag }}" --target "$RELEASE_COMMIT" $PRERELEASE_FLAG --title "$ANNOUNCEMENT_TITLE" --notes-file "$RUNNER_TEMP/notes.txt" artifacts/*
announce:
needs:
- plan
- host
# use "always() && ..." to allow us to wait for all publish jobs while
# still allowing individual publish jobs to skip themselves (for prereleases).
# "host" however must run to completion, no skipping allowed!
if: ${{ always() && needs.host.result == 'success' }}
runs-on: "ubuntu-22.04"
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
steps:
- uses: actions/checkout@v6
with:
persist-credentials: false
submodules: recursive

1484
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -9,8 +9,14 @@ members = [
"crates/noise", "crates/noise",
"crates/palette-compress", "crates/palette-compress",
"crates/biome-db", "crates/biome-db",
"crates/wasm-host",
"crates/scripting",
"crates/i18n",
"bench", "bench",
] ]
exclude = [
"plugins/example-noisemap",
]
[workspace.dependencies.criterion] [workspace.dependencies.criterion]
version = "0.5" version = "0.5"
@ -21,6 +27,7 @@ edition = "2024"
version = "0.1.0" version = "0.1.0"
license = "GPL-3.0-or-later" license = "GPL-3.0-or-later"
authors = ["Loki"] authors = ["Loki"]
repository = "https://github.com/loki5512344/Terrafier"
description = "A high-performance Minecraft world painter, written in Rust" description = "A high-performance Minecraft world painter, written in Rust"
[workspace.dependencies] [workspace.dependencies]
@ -35,13 +42,39 @@ image = { version = "0.25", default-features = false, features = ["png"] }
log = "0.4" log = "0.4"
env_logger = "0.11" env_logger = "0.11"
serde-big-array = "0.5" serde-big-array = "0.5"
bincode = "1.3" bincode = { version = "2", features = ["serde"] }
flate2 = "1.1" flate2 = "1.1"
# Config for 'dist'
[workspace.metadata.dist] [workspace.metadata.dist]
# Инициализируется при первом запуске cargo dist init # The preferred dist version to use in CI (Cargo.toml SemVer syntax)
cargo-dist-version = "0.31.0"
rust-toolchain = "stable"
# CI backends to support
ci = "github"
# Target platforms to build apps for (Rust target-triple syntax)
targets = ["aarch64-apple-darwin", "x86_64-apple-darwin", "x86_64-unknown-linux-gnu", "x86_64-pc-windows-msvc"]
# The installers to generate for each app
installers = ["shell", "msi", "pkg"]
# The archive format to use for non-windows builds (defaults .tar.xz)
unix-archive = ".tar.gz"
# The archive format to use for windows builds (defaults .zip)
windows-archive = ".zip"
# Path that installers should place binaries in
install-path = "CARGO_HOME"
# Whether to install an updater program
install-updater = false
[workspace.metadata.dist.mac-pkg-config]
identifier = "com.terrafier.app"
install-location = "/usr/local"
[profile.release] [profile.release]
opt-level = 3 opt-level = 3
lto = true lto = true
codegen-units = 1 codegen-units = 1
# The profile that 'dist' will build with
[profile.dist]
inherits = "release"
lto = "thin"

38
PLAN.md
View file

@ -17,7 +17,9 @@
│ │World │Tile │Export │Import │ │ │ │World │Tile │Export │Import │ │
│ │Model │Ops │Pipeline│Anvil │ │ │ │Model │Ops │Pipeline│Anvil │ │
│ └──────┴──────┴────────┴────────┘ │ │ └──────┴──────┴────────┴────────┘ │
├────────────────────────────────────────────────────┤ ├──────────────────────┬─────────────────────────────┤
│ wasm-host / rhai │ Плагины и скриптинг │
├──────────────────────┴─────────────────────────────┤
│ Foundation crates │ │ Foundation crates │
│ nbt │ fastanvil │ noise │ palette-compress │ │ nbt │ fastanvil │ noise │ palette-compress │
│ biome-db │ │ biome-db │
@ -39,10 +41,10 @@
- **Heightmap**: noise-based генерация (OpenSimplex), flat, combined - **Heightmap**: noise-based генерация (OpenSimplex), flat, combined
### В работе / не реализовано ### В работе / не реализовано
- **GPU-рендеринг (wgpu)** — ключевая фича, пока весь рендер CPU - **WASM плагины** — wasmtime-хост, ABI, загрузка .wasm файлов
- **Слои**: Caves, Rivers, Frost, Trees, Resources, Biome - **Скриптинг** — Rhai-движок для world-генераторов
- **Импорт .world** (WorldPainter format) - **i18n** — локализация CLI/GUI
- **Плагины** (WASM) - **GUI polish** — pan/zoom GPU viewport, brush overlay, layer panel
## Стек ## Стек
@ -76,23 +78,29 @@ NBT парсер, Anvil reader, workspace setup
Tile, Dimension, World, Terrain, Brush, Operation Tile, Dimension, World, Terrain, Brush, Operation
### Phase 2 (done) — Minecraft I/O + CLI ### Phase 2 (done) — Minecraft I/O + CLI
Импорт/экспорт Java Edition, CLI команды Импорт/экспорт Java Edition, CLI команды, импорт .world
### Phase 3 — Operations & Layers ### Phase 3 (done) — Operations & Layers
Инструменты (raise, erode, smooth, flatten, fill, paint) Инструменты (raise, erode, smooth, flatten, fill, paint)
Слои (caves, river, frost, trees, biome, resources) Слои (caves, river, frost, trees, biome, resources)
Экспортёры слоёв Экспортёры слоёв, фильтры, MultiTile
### Phase 4 — GUI ### Phase 4 (done) — GUI (MVP)
GPU-рендеринг через wgpu (шейдеры, 30-60 FPS) GPU-рендеринг через wgpu (базовый), панель инструментов
Панорамирование, зум, оверлей кисти Диалоги (New World, Export), undo/redo
Панель слоёв, инструментов, диалоги
### Phase 5 — Полировка ### Phase 5 — WASM Plugins + Scripting
Бенчмарки, оптимизация, i18n, пакеты (AppImage/msi/app) WASM-хост на wasmtime, ABI для плагинов
Rhai-скриптинг для world-генераторов
Загрузка плагинов из .wasm файлов, registry
### Phase 6 — i18n + Инфраструктура
Локализация CLI/GUI (rust-i18n / Fluent)
CI/CD, cargo-dist, AppImage/msi/app
Бенчмарки, оптимизация, polish
### Будущее ### Будущее
Импорт .world, WASM плагины, Bedrock Edition, скриптинг Bedrock Edition, GUI pan/zoom/brush-overlay, GUI layer panel
## Модель данных ## Модель данных

View file

@ -11,8 +11,8 @@ fn make_small_compound() -> Tag {
map.insert("short".into(), Tag::Short(1000)); map.insert("short".into(), Tag::Short(1000));
map.insert("int".into(), Tag::Int(1_000_000)); map.insert("int".into(), Tag::Int(1_000_000));
map.insert("long".into(), Tag::Long(1_000_000_000)); map.insert("long".into(), Tag::Long(1_000_000_000));
map.insert("float".into(), Tag::Float(3.14)); map.insert("float".into(), Tag::Float(std::f64::consts::PI as f32));
map.insert("double".into(), Tag::Double(2.718)); map.insert("double".into(), Tag::Double(std::f64::consts::E));
map.insert("string".into(), Tag::String("Hello, Benchmark!".into())); map.insert("string".into(), Tag::String("Hello, Benchmark!".into()));
map.insert( map.insert(
"inner".into(), "inner".into(),

View file

@ -3,13 +3,26 @@ name = "terrafier-cli"
version.workspace = true version.workspace = true
edition.workspace = true edition.workspace = true
license.workspace = true license.workspace = true
repository.workspace = true
authors.workspace = true
description = "CLI tool for Terrafier — create, edit, export Minecraft worlds from the command line" description = "CLI tool for Terrafier — create, edit, export Minecraft worlds from the command line"
[package.metadata.wix]
upgrade-guid = "351D0A14-B7C2-412B-BEB5-BD394ED35C94"
path-guid = "C4FB2FF3-085A-4CDF-B823-B0A2EB5B8220"
license = false
eula = false
[dependencies] [dependencies]
terrafier-core = { path = "../core" } terrafier-core = { path = "../core" }
terrafier-scripting = { path = "../crates/scripting", optional = true }
terrafier-i18n = { path = "../crates/i18n" }
clap.workspace = true clap.workspace = true
anyhow.workspace = true anyhow.workspace = true
indicatif.workspace = true indicatif.workspace = true
log.workspace = true log.workspace = true
env_logger.workspace = true env_logger.workspace = true
serde_json.workspace = true serde_json.workspace = true
[features]
scripting = ["terrafier-scripting"]

View file

@ -1,5 +1,6 @@
use clap::Args; use clap::Args;
use std::path::Path; use std::path::Path;
use terrafier_i18n as i18n;
use crate::util::output::{self, OutputFormat}; use crate::util::output::{self, OutputFormat};
use crate::util::{load_world, progress}; use crate::util::{load_world, progress};
@ -31,6 +32,9 @@ pub fn cmd_export(
let tile_count: usize = world.dimensions.iter().map(|d| d.tiles.len()).sum(); let tile_count: usize = world.dimensions.iter().map(|d| d.tiles.len()).sum();
if validate || dry_run { if validate || dry_run {
if dry_run {
log::info!("{}", i18n::translate_with("cli.dry_run", &[("action", "export")]));
}
output::print_result( output::print_result(
format, format,
&serde_json::json!({ &serde_json::json!({
@ -45,9 +49,10 @@ pub fn cmd_export(
} }
} }
let bar = progress::new_bar(tile_count as u64, "Exporting..."); let exporting_msg = i18n::translate_with("cli.export.exporting", &[("path", &args.output)]);
let bar = progress::new_bar(tile_count as u64, &exporting_msg);
terrafier_core::io::export::export_to_save(&world, Path::new(&args.output))?; terrafier_core::io::export::export_to_save(&world, Path::new(&args.output))?;
progress::finish_with("Export complete", bar); progress::finish_with(i18n::t!("cli.export.complete"), bar);
log::info!("World exported to: {}", args.output); log::info!("World exported to: {}", args.output);
output::print_result( output::print_result(

View file

@ -1,5 +1,6 @@
use clap::Args; use clap::Args;
use std::path::Path; use std::path::Path;
use terrafier_i18n as i18n;
use crate::util::output::{self, OutputFormat}; use crate::util::output::{self, OutputFormat};
use crate::util::progress; use crate::util::progress;
@ -22,6 +23,9 @@ pub fn cmd_import(
log::info!("Importing Minecraft save from: {}", input_path.display()); log::info!("Importing Minecraft save from: {}", input_path.display());
if validate || dry_run { if validate || dry_run {
if dry_run {
log::info!("{}", i18n::translate_with("cli.dry_run", &[("action", "import")]));
}
terrafier_core::io::import::validate_save(input_path)?; terrafier_core::io::import::validate_save(input_path)?;
output::print_result( output::print_result(
format, format,
@ -46,9 +50,15 @@ pub fn cmd_import(
} else { } else {
0 0
}; };
let bar = progress::new_bar(region_count.max(1) as u64, "Importing regions..."); let importing_msg = i18n::translate_with("cli.import.importing", &[("path", &args.input)]);
let bar = progress::new_bar(region_count.max(1) as u64, &importing_msg);
let world = terrafier_core::io::import::import(input_path)?; let world = terrafier_core::io::import::import(input_path)?;
progress::finish_with("Import complete", bar); let tile_count: usize = world.dimensions.iter().map(|d| d.tiles.len()).sum();
let import_msg = i18n::translate_with("cli.import.complete", &[
("tiles", &tile_count.to_string()),
("dimensions", &world.dimensions.len().to_string()),
]);
progress::finish_with(&import_msg, bar);
let output_path = Path::new(&args.output); let output_path = Path::new(&args.output);
std::fs::create_dir_all(output_path)?; std::fs::create_dir_all(output_path)?;
@ -56,7 +66,6 @@ pub fn cmd_import(
let world_json = serde_json::to_string_pretty(&world)?; let world_json = serde_json::to_string_pretty(&world)?;
std::fs::write(output_path.join("world.tfw"), &world_json)?; std::fs::write(output_path.join("world.tfw"), &world_json)?;
let tile_count: usize = world.dimensions.iter().map(|d| d.tiles.len()).sum();
log::info!( log::info!(
"Import complete: {} dimensions, {} tiles", "Import complete: {} dimensions, {} tiles",
world.dimensions.len(), world.dimensions.len(),

View file

@ -1,5 +1,6 @@
use clap::Args; use clap::Args;
use std::path::PathBuf; use std::path::PathBuf;
use terrafier_i18n as i18n;
use crate::util::output::{self, OutputFormat}; use crate::util::output::{self, OutputFormat};
use crate::util::{load_world, progress}; use crate::util::{load_world, progress};
@ -31,6 +32,9 @@ pub fn cmd_render(
let tile_count: usize = world.dimensions.iter().map(|d| d.tiles.len()).sum(); let tile_count: usize = world.dimensions.iter().map(|d| d.tiles.len()).sum();
if validate || dry_run { if validate || dry_run {
if dry_run {
log::info!("{}", i18n::translate_with("cli.dry_run", &[("action", "render")]));
}
output::print_result( output::print_result(
format, format,
&serde_json::json!({ &serde_json::json!({
@ -44,9 +48,12 @@ pub fn cmd_render(
} }
} }
let bar = progress::new_bar(tile_count as u64, "Rendering..."); let bar = progress::new_bar(tile_count as u64, i18n::t!("cli.render.rendering"));
terrafier_core::io::export::render_to_image(&world, &args.output, args.scale)?; terrafier_core::io::export::render_to_image(&world, &args.output, args.scale)?;
progress::finish_with("Render complete", bar); let saved_msg = i18n::translate_with("cli.render.saved", &[
("path", &args.output.display().to_string()),
]);
progress::finish_with(&saved_msg, bar);
log::info!("Preview rendered to: {}", args.output.display()); log::info!("Preview rendered to: {}", args.output.display());
output::print_result( output::print_result(

View file

@ -5,3 +5,8 @@ pub use io::render;
pub mod info; pub mod info;
pub mod new; pub mod new;
#[cfg(feature = "scripting")]
mod run;
#[cfg(feature = "scripting")]
pub use run::cmd_run;

View file

@ -1,6 +1,7 @@
use clap::Args; use clap::Args;
use std::path::PathBuf; use std::path::PathBuf;
use terrafier_core::World; use terrafier_core::World;
use terrafier_i18n as i18n;
use crate::util::output::{self, OutputFormat}; use crate::util::output::{self, OutputFormat};
use crate::util::progress; use crate::util::progress;
@ -42,9 +43,14 @@ pub fn cmd_new(
} }
} }
let spinner = progress::new_spinner("Generating world..."); let spinner = progress::new_spinner(i18n::t!("cli.new.creating"));
let world = World::new(&args.name, args.seed); let world = World::new(&args.name, args.seed);
progress::finish_with("World generated", spinner); let tile_count = world.dimensions.iter().map(|d| d.tiles.len()).sum::<usize>();
let created_msg = i18n::translate_with("cli.new.created", &[
("name", &args.name),
("tiles", &tile_count.to_string()),
]);
progress::finish_with(&created_msg, spinner);
let world_dir = args.output.join(&args.name); let world_dir = args.output.join(&args.name);
std::fs::create_dir_all(&world_dir)?; std::fs::create_dir_all(&world_dir)?;
@ -52,7 +58,6 @@ pub fn cmd_new(
let world_json = serde_json::to_string_pretty(&world)?; let world_json = serde_json::to_string_pretty(&world)?;
std::fs::write(world_dir.join("world.tfw"), &world_json)?; std::fs::write(world_dir.join("world.tfw"), &world_json)?;
let dim = &world.dimensions[0];
log::info!("World saved to: {}", world_dir.display()); log::info!("World saved to: {}", world_dir.display());
output::print_result( output::print_result(
format, format,
@ -61,7 +66,7 @@ pub fn cmd_new(
"name": args.name, "name": args.name,
"seed": args.seed, "seed": args.seed,
"path": world_dir.display().to_string(), "path": world_dir.display().to_string(),
"tiles": dim.tiles.len(), "tiles": tile_count,
}), }),
); );

8
cli/src/cmds/run.rs Normal file
View file

@ -0,0 +1,8 @@
use anyhow::Result;
use std::path::PathBuf;
pub fn cmd_run(script: &PathBuf) -> Result<()> {
terrafier_scripting::run_script(script)?;
println!("Script executed successfully.");
Ok(())
}

View file

@ -1,4 +1,5 @@
use clap::Parser; use clap::Parser;
use std::path::PathBuf;
mod cmds; mod cmds;
mod util; mod util;
@ -38,9 +39,18 @@ enum Commands {
Info(cmds::info::InfoArgs), Info(cmds::info::InfoArgs),
/// Render world preview to an image /// Render world preview to an image
Render(cmds::render::RenderArgs), Render(cmds::render::RenderArgs),
/// Run a Terrafier script (.rhai)
#[cfg(feature = "scripting")]
Run {
/// Path to .rhai script file
script: PathBuf,
},
} }
fn main() { fn main() {
use terrafier_i18n as i18n;
i18n::init();
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")) env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info"))
.format_timestamp(None) .format_timestamp(None)
.init(); .init();
@ -60,6 +70,8 @@ fn main() {
Commands::Render(args) => { Commands::Render(args) => {
cmds::render::cmd_render(args, &format, cli.dry_run, cli.validate) cmds::render::cmd_render(args, &format, cli.dry_run, cli.validate)
} }
#[cfg(feature = "scripting")]
Commands::Run { script } => cmds::cmd_run(&script),
}; };
if let Err(e) = result { if let Err(e) = result {

View file

@ -1,4 +1,5 @@
use serde_json::Value; use serde_json::Value;
use terrafier_i18n as i18n;
pub enum OutputFormat { pub enum OutputFormat {
Json, Json,
@ -17,7 +18,11 @@ impl OutputFormat {
pub fn print_result(format: &OutputFormat, result: &Value) { pub fn print_result(format: &OutputFormat, result: &Value) {
match format { match format {
OutputFormat::Json => { OutputFormat::Json => {
println!("{}", serde_json::to_string(result).unwrap()); println!(
"{}",
serde_json::to_string(result)
.unwrap_or_else(|e| format!("serialization error: {e}"))
);
} }
OutputFormat::Human => print_human(result), OutputFormat::Human => print_human(result),
} }
@ -31,68 +36,116 @@ fn print_human(result: &Value) {
let seed = result["seed"].as_u64().unwrap_or(0); let seed = result["seed"].as_u64().unwrap_or(0);
let tiles = result["tiles"].as_u64().unwrap_or(0); let tiles = result["tiles"].as_u64().unwrap_or(0);
println!( println!(
"Created world '{}' (seed: {}, tiles: {})", "{}",
name, seed, tiles i18n::translate_with("cli.new.created", &[
("name", name),
("tiles", &tiles.to_string()),
])
); );
let _ = seed;
} }
"exported" => { "exported" => {
let input = result["input"].as_str().unwrap_or("?");
let output = result["output"].as_str().unwrap_or("?");
let tiles = result["tiles"].as_u64().unwrap_or(0); let tiles = result["tiles"].as_u64().unwrap_or(0);
println!("Exported {} tiles from '{}' to '{}'", tiles, input, output); println!(
"{}",
i18n::translate_with("cli.export.complete", &[
("regions", "?"),
("chunks", &tiles.to_string()),
])
);
} }
"imported" => { "imported" => {
let name = result["name"].as_str().unwrap_or("?"); let name = result["name"].as_str().unwrap_or("?");
let seed = result["seed"].as_u64().unwrap_or(0);
let dims = result["dimensions"].as_u64().unwrap_or(0);
let tiles = result["tiles"].as_u64().unwrap_or(0); let tiles = result["tiles"].as_u64().unwrap_or(0);
let dims = result["dimensions"].as_u64().unwrap_or(0);
println!( println!(
"Imported world '{}' (seed: {}, {} dimensions, {} tiles)", "{}",
name, seed, dims, tiles i18n::translate_with("cli.import.complete", &[
("tiles", &tiles.to_string()),
("dimensions", &dims.to_string()),
])
); );
let _ = name;
} }
"rendered" => { "rendered" => {
let world = result["world"].as_str().unwrap_or("?");
let output = result["output"].as_str().unwrap_or("?"); let output = result["output"].as_str().unwrap_or("?");
println!("Rendered world '{}' to '{}'", world, output); println!(
"{}",
i18n::translate_with("cli.render.saved", &[
("path", output),
])
);
} }
"validated" => { "validated" => {
println!("Validation passed: {:?}", result); println!("{}", i18n::t!("cli.validate.ok"));
} }
_ => { _ => {
println!("{}", serde_json::to_string_pretty(result).unwrap()); println!(
"{}",
serde_json::to_string_pretty(result)
.unwrap_or_else(|e| format!("serialization error: {e}"))
);
} }
} }
} else if result.get("name").is_some() { } else if result.get("name").is_some() {
// Info output // Info output
println!("World: {}", result["name"].as_str().unwrap_or("?")); print_human_info(result);
} else {
println!(
"{}",
serde_json::to_string_pretty(result)
.unwrap_or_else(|e| format!("serialization error: {e}"))
);
}
}
fn print_human_info(result: &Value) {
let name = result["name"].as_str().unwrap_or("?");
println!(
"{}",
i18n::translate_with("cli.info.title", &[("name", name)])
);
if let Some(seed) = result.get("seed").and_then(|v| v.as_u64()) { if let Some(seed) = result.get("seed").and_then(|v| v.as_u64()) {
println!(" Seed: {}", seed); println!(
" {}",
i18n::translate_with("cli.info.seed", &[("seed", &seed.to_string())])
);
} }
if let Some(platform) = result.get("platform").and_then(|v| v.as_str()) { if let Some(platform) = result.get("platform").and_then(|v| v.as_str()) {
println!(" Platform: {}", platform); println!(
" {}",
i18n::translate_with("cli.info.platform", &[("platform", platform)])
);
} }
if let Some(height) = result.get("height_range") { if let Some(height) = result.get("height_range") {
let min = height["min"].as_i64().unwrap_or(0); let min = height["min"].as_i64().unwrap_or(0);
let max = height["max"].as_i64().unwrap_or(0); let max = height["max"].as_i64().unwrap_or(0);
println!(" Height range: {} to {}", min, max); eprintln!(" Height range: {} to {}", min, max);
} }
if let Some(dims) = result.get("dimensions").and_then(|v| v.as_array()) { if let Some(dims) = result.get("dimensions").and_then(|v| v.as_array()) {
println!(" Dimensions:"); println!(
" {}",
i18n::translate_with("cli.info.dimensions", &[
("dimensions", &dims.len().to_string()),
])
);
for dim in dims { for dim in dims {
let name = dim["name"].as_str().unwrap_or("?"); let dim_name = dim["name"].as_str().unwrap_or("?");
let tiles = dim["tiles"].as_u64().unwrap_or(0); let dim_tiles = dim["tiles"].as_u64().unwrap_or(0);
println!(" - {} ({} tiles)", name, tiles); println!(
" - {} ({} tiles)",
dim_name, dim_tiles
);
} }
} }
if let Some(tiles) = result.get("total_tiles").and_then(|v| v.as_u64()) { if let Some(tiles) = result.get("total_tiles").and_then(|v| v.as_u64()) {
println!(" Total tiles: {}", tiles); println!(
" {}",
i18n::translate_with("cli.info.tiles", &[("tiles", &tiles.to_string())])
);
} }
if let Some(path) = result.get("path").and_then(|v| v.as_str()) { if let Some(path) = result.get("path").and_then(|v| v.as_str()) {
println!(" Path: {}", path); eprintln!(" Path: {}", path);
}
} else {
println!("{}", serde_json::to_string_pretty(result).unwrap());
} }
} }
@ -101,11 +154,12 @@ pub fn print_error(format: &OutputFormat, error: &anyhow::Error) {
OutputFormat::Json => { OutputFormat::Json => {
eprintln!( eprintln!(
"{}", "{}",
serde_json::to_string(&serde_json::json!({"error": error.to_string()})).unwrap() serde_json::to_string(&serde_json::json!({"error": error.to_string()}))
.unwrap_or_else(|e| format!("serialization error: {e}"))
); );
} }
OutputFormat::Human => { OutputFormat::Human => {
eprintln!("Error: {}", error); eprintln!("{}: {}", i18n::t!("cli.status.error"), error);
} }
} }
} }
@ -116,7 +170,8 @@ pub fn print_info(format: &OutputFormat, msg: &str) {
OutputFormat::Json => { OutputFormat::Json => {
println!( println!(
"{}", "{}",
serde_json::to_string(&serde_json::json!({"info": msg})).unwrap() serde_json::to_string(&serde_json::json!({"info": msg}))
.unwrap_or_else(|e| format!("serialization error: {e}"))
); );
} }
OutputFormat::Human => { OutputFormat::Human => {

228
cli/wix/main.wxs Normal file
View file

@ -0,0 +1,228 @@
<?xml version='1.0' encoding='windows-1252'?>
<!--
Copyright (C) 2017 Christopher R. Field.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<!--
The "cargo wix" subcommand provides a variety of predefined variables available
for customization of this template. The values for each variable are set at
installer creation time. The following variables are available:
TargetTriple = The rustc target triple name.
TargetEnv = The rustc target environment. This is typically either
"msvc" or "gnu" depending on the toolchain downloaded and
installed.
TargetVendor = The rustc target vendor. This is typically "pc", but Rust
does support other vendors, like "uwp".
CargoTargetBinDir = The complete path to the directory containing the
binaries (exes) to include. The default would be
"target\release\". If an explicit rustc target triple is
used, i.e. cross-compiling, then the default path would
be "target\<CARGO_TARGET>\<CARGO_PROFILE>",
where "<CARGO_TARGET>" is replaced with the "CargoTarget"
variable value and "<CARGO_PROFILE>" is replaced with the
value from the "CargoProfile" variable. This can also
be overridden manually with the "target-bin-dir" flag.
CargoTargetDir = The path to the directory for the build artifacts, i.e.
"target".
CargoProfile = The cargo profile used to build the binaries
(usually "debug" or "release").
Version = The version for the installer. The default is the
"Major.Minor.Fix" semantic versioning number of the Rust
package.
-->
<!--
Please do not remove these pre-processor If-Else blocks. These are used with
the `cargo wix` subcommand to automatically determine the installation
destination for 32-bit versus 64-bit installers. Removal of these lines will
cause installation errors.
-->
<?if $(sys.BUILDARCH) = x64 or $(sys.BUILDARCH) = arm64 ?>
<?define PlatformProgramFilesFolder = "ProgramFiles64Folder" ?>
<?else ?>
<?define PlatformProgramFilesFolder = "ProgramFilesFolder" ?>
<?endif ?>
<Wix xmlns='http://schemas.microsoft.com/wix/2006/wi'>
<Product
Id='*'
Name='terrafier-cli'
UpgradeCode='351D0A14-B7C2-412B-BEB5-BD394ED35C94'
Manufacturer='Loki'
Language='1033'
Codepage='1252'
Version='$(var.Version)'>
<Package Id='*'
Keywords='Installer'
Description='CLI tool for Terrafier — create, edit, export Minecraft worlds from the command line'
Manufacturer='Loki'
InstallerVersion='450'
Languages='1033'
Compressed='yes'
InstallScope='perMachine'
SummaryCodepage='1252'
/>
<MajorUpgrade
Schedule='afterInstallInitialize'
DowngradeErrorMessage='A newer version of [ProductName] is already installed. Setup will now exit.'/>
<Media Id='1' Cabinet='media1.cab' EmbedCab='yes' DiskPrompt='CD-ROM #1'/>
<Property Id='DiskPrompt' Value='terrafier-cli Installation'/>
<Directory Id='TARGETDIR' Name='SourceDir'>
<Directory Id='$(var.PlatformProgramFilesFolder)' Name='PFiles'>
<Directory Id='APPLICATIONFOLDER' Name='terrafier-cli'>
<!--
Enabling the license sidecar file in the installer is a four step process:
1. Uncomment the `Component` tag and its contents.
2. Change the value for the `Source` attribute in the `File` tag to a path
to the file that should be included as the license sidecar file. The path
can, and probably should be, relative to this file.
3. Change the value for the `Name` attribute in the `File` tag to the
desired name for the file when it is installed alongside the `bin` folder
in the installation directory. This can be omitted if the desired name is
the same as the file name.
4. Uncomment the `ComponentRef` tag with the Id attribute value of "License"
further down in this file.
-->
<!--
<Component Id='License' Guid='*'>
<File Id='LicenseFile' Name='ChangeMe' DiskId='1' Source='C:\Path\To\File' KeyPath='yes'/>
</Component>
-->
<Directory Id='Bin' Name='bin'>
<Component Id='Path' Guid='C4FB2FF3-085A-4CDF-B823-B0A2EB5B8220' KeyPath='yes'>
<Environment
Id='PATH'
Name='PATH'
Value='[Bin]'
Permanent='no'
Part='last'
Action='set'
System='yes'/>
</Component>
<Component Id='binary0' Guid='*'>
<File
Id='exe0'
Name='terrafier-cli.exe'
DiskId='1'
Source='$(var.CargoTargetBinDir)\terrafier-cli.exe'
KeyPath='yes'/>
</Component>
</Directory>
</Directory>
</Directory>
</Directory>
<Feature
Id='Binaries'
Title='Application'
Description='Installs all binaries and the license.'
Level='1'
ConfigurableDirectory='APPLICATIONFOLDER'
AllowAdvertise='no'
Display='expand'
Absent='disallow'>
<!--
Uncomment the following `ComponentRef` tag to add the license
sidecar file to the installer.
-->
<!--<ComponentRef Id='License'/>-->
<ComponentRef Id='binary0'/>
<Feature
Id='Environment'
Title='PATH Environment Variable'
Description='Add the install location of the [ProductName] executable to the PATH system environment variable. This allows the [ProductName] executable to be called from any location.'
Level='1'
Absent='allow'>
<ComponentRef Id='Path'/>
</Feature>
</Feature>
<SetProperty Id='ARPINSTALLLOCATION' Value='[APPLICATIONFOLDER]' After='CostFinalize'/>
<!--
Uncomment the following `Icon` and `Property` tags to change the product icon.
The product icon is the graphic that appears in the Add/Remove
Programs control panel for the application.
-->
<!--<Icon Id='ProductICO' SourceFile='wix\Product.ico'/>-->
<!--<Property Id='ARPPRODUCTICON' Value='ProductICO' />-->
<Property Id='ARPHELPLINK' Value='https://github.com/loki5512344/Terrafier'/>
<UI>
<UIRef Id='WixUI_FeatureTree'/>
<!--
Enabling the EULA dialog in the installer is a three step process:
1. Comment out or remove the two `Publish` tags that follow the
`WixVariable` tag.
2. Uncomment the `<WixVariable Id='WixUILicenseRtf' Value='Path\to\Eula.rft'>` tag further down
3. Replace the `Value` attribute of the `WixVariable` tag with
the path to a RTF file that will be used as the EULA and
displayed in the license agreement dialog.
-->
<Publish Dialog='WelcomeDlg' Control='Next' Event='NewDialog' Value='CustomizeDlg' Order='99'>1</Publish>
<Publish Dialog='CustomizeDlg' Control='Back' Event='NewDialog' Value='WelcomeDlg' Order='99'>1</Publish>
</UI>
<!--
Enabling the EULA dialog in the installer requires uncommenting
the following `WixUILicenseRTF` tag and changing the `Value`
attribute.
-->
<!-- <WixVariable Id='WixUILicenseRtf' Value='Relative\Path\to\Eula.rtf'/> -->
<!--
Uncomment the next `WixVariable` tag to customize the installer's
Graphical User Interface (GUI) and add a custom banner image across
the top of each screen. See the WiX Toolset documentation for details
about customization.
The banner BMP dimensions are 493 x 58 pixels.
-->
<!--<WixVariable Id='WixUIBannerBmp' Value='wix\Banner.bmp'/>-->
<!--
Uncomment the next `WixVariable` tag to customize the installer's
Graphical User Interface (GUI) and add a custom image to the first
dialog, or screen. See the WiX Toolset documentation for details about
customization.
The dialog BMP dimensions are 493 x 312 pixels.
-->
<!--<WixVariable Id='WixUIDialogBmp' Value='wix\Dialog.bmp'/>-->
</Product>
</Wix>

View file

@ -73,7 +73,10 @@ fn terrafier_biome_name(id: u8) -> &'static str {
arr arr
}); });
names.get(id as usize).copied().unwrap_or("minecraft:plains") names
.get(id as usize)
.copied()
.unwrap_or("minecraft:plains")
} }
pub struct BiomeLayerExport; pub struct BiomeLayerExport;

View file

@ -57,10 +57,10 @@ pub fn biome_name(
"minecraft:plains" "minecraft:plains"
} }
mod biome_res;
mod caves_frost; mod caves_frost;
mod river_trees; mod river_trees;
mod biome_res;
pub use biome_res::{BiomeLayerExport, ResourcesLayerExport};
pub use caves_frost::{CavesLayerExport, FrostLayerExport}; pub use caves_frost::{CavesLayerExport, FrostLayerExport};
pub use river_trees::{RiverLayerExport, TreesLayerExport}; pub use river_trees::{RiverLayerExport, TreesLayerExport};
pub use biome_res::{BiomeLayerExport, ResourcesLayerExport};

View file

@ -1,9 +1,9 @@
use std::collections::{BTreeSet, HashMap};
use crate::io::layer_export::{apply_layers, biome_name, LayerExport};
use crate::io::minecraft::{data_version, Result};
use crate::model::tile::Tile;
use super::blocks::{bits_needed, pack_indices, BLOCK_SET, BLOCK_SET_EXTENDED};
use super::block_name; use super::block_name;
use super::blocks::{BLOCK_SET, BLOCK_SET_EXTENDED, bits_needed, pack_indices};
use crate::io::layer_export::{LayerExport, apply_layers, biome_name};
use crate::io::minecraft::{Result, data_version};
use crate::model::tile::Tile;
use std::collections::{BTreeSet, HashMap};
fn make_section( fn make_section(
sec_y: i32, sec_y: i32,
@ -12,7 +12,10 @@ fn make_section(
) -> terrafier_nbt::Tag { ) -> terrafier_nbt::Tag {
let mut sec = HashMap::new(); let mut sec = HashMap::new();
sec.insert("Y".into(), terrafier_nbt::Tag::Byte(sec_y as i8)); sec.insert("Y".into(), terrafier_nbt::Tag::Byte(sec_y as i8));
sec.insert("block_states".into(), terrafier_nbt::Tag::Compound(block_states)); sec.insert(
"block_states".into(),
terrafier_nbt::Tag::Compound(block_states),
);
sec.insert("biomes".into(), terrafier_nbt::Tag::Compound(biomes)); sec.insert("biomes".into(), terrafier_nbt::Tag::Compound(biomes));
terrafier_nbt::Tag::Compound(sec) terrafier_nbt::Tag::Compound(sec)
} }
@ -38,7 +41,10 @@ pub(super) fn build_chunk_nbt(
let mut compound = HashMap::new(); let mut compound = HashMap::new();
compound.insert("xPos".into(), terrafier_nbt::Tag::Int(chunk_x)); compound.insert("xPos".into(), terrafier_nbt::Tag::Int(chunk_x));
compound.insert("zPos".into(), terrafier_nbt::Tag::Int(chunk_z)); compound.insert("zPos".into(), terrafier_nbt::Tag::Int(chunk_z));
compound.insert("DataVersion".into(), terrafier_nbt::Tag::Int(data_version())); compound.insert(
"DataVersion".into(),
terrafier_nbt::Tag::Int(data_version()),
);
compound.insert("Status".into(), terrafier_nbt::Tag::String("full".into())); compound.insert("Status".into(), terrafier_nbt::Tag::String("full".into()));
let min_sec = (tile.min_height as i32 >> 4).max(-4); let min_sec = (tile.min_height as i32 >> 4).max(-4);
let max_sec = (tile.max_height as i32 >> 4).min(20); let max_sec = (tile.max_height as i32 >> 4).min(20);
@ -104,7 +110,10 @@ pub(super) fn build_chunk_nbt_with_layers(
let mut compound = HashMap::new(); let mut compound = HashMap::new();
compound.insert("xPos".into(), terrafier_nbt::Tag::Int(chunk_x)); compound.insert("xPos".into(), terrafier_nbt::Tag::Int(chunk_x));
compound.insert("zPos".into(), terrafier_nbt::Tag::Int(chunk_z)); compound.insert("zPos".into(), terrafier_nbt::Tag::Int(chunk_z));
compound.insert("DataVersion".into(), terrafier_nbt::Tag::Int(data_version())); compound.insert(
"DataVersion".into(),
terrafier_nbt::Tag::Int(data_version()),
);
compound.insert("Status".into(), terrafier_nbt::Tag::String("full".into())); compound.insert("Status".into(), terrafier_nbt::Tag::String("full".into()));
let min_sec = (tile.min_height as i32 >> 4).max(-4); let min_sec = (tile.min_height as i32 >> 4).max(-4);
let max_sec = (tile.max_height as i32 >> 4).min(20); let max_sec = (tile.max_height as i32 >> 4).min(20);
@ -125,7 +134,12 @@ pub(super) fn build_chunk_nbt_with_layers(
let terrain_id = tile.terrain[tile_lz * 128 + tile_lx]; let terrain_id = tile.terrain[tile_lz * 128 + tile_lx];
let base_name = block_name(terrain_id, global_y, surface_y); let base_name = block_name(terrain_id, global_y, surface_y);
let name = apply_layers( let name = apply_layers(
tile, tile_lx, tile_lz, global_y, surface_y, base_name, tile,
tile_lx,
tile_lz,
global_y,
surface_y,
base_name,
layer_exporters, layer_exporters,
); );
BLOCK_SET_EXTENDED BLOCK_SET_EXTENDED

View file

@ -2,6 +2,6 @@ mod blocks;
mod chunk; mod chunk;
mod writer; mod writer;
pub(crate) use blocks::block_name;
pub use writer::save_world; pub use writer::save_world;
pub use writer::save_world_with_layers; pub use writer::save_world_with_layers;
pub(crate) use blocks::block_name;

View file

@ -5,7 +5,7 @@ use std::fs;
use std::path::Path; use std::path::Path;
use crate::io::layer_export::LayerExport; use crate::io::layer_export::LayerExport;
use crate::io::minecraft::{data_version, Result}; use crate::io::minecraft::{Result, data_version};
use crate::model::tile::Tile; use crate::model::tile::Tile;
use crate::model::world::World; use crate::model::world::World;
@ -47,8 +47,7 @@ pub fn save_world(world: &World, output_path: &Path) -> Result<()> {
let region_local_x = (chunk_x & 31) as u8; let region_local_x = (chunk_x & 31) as u8;
let region_local_z = (chunk_z & 31) as u8; let region_local_z = (chunk_z & 31) as u8;
let chunk_data = let chunk_data = build_chunk_nbt(chunk_x, chunk_z, tile, chunk_lx, chunk_lz)?;
build_chunk_nbt(chunk_x, chunk_z, tile, chunk_lx, chunk_lz)?;
if chunk_data.is_empty() { if chunk_data.is_empty() {
continue; continue;

View file

@ -2,12 +2,12 @@ mod export;
mod reader; mod reader;
mod version; mod version;
pub use reader::load_save;
pub use reader::discover_region_files;
pub use export::save_world; pub use export::save_world;
pub use export::save_world_with_layers; pub use export::save_world_with_layers;
pub use version::version_from_data_version; pub use reader::discover_region_files;
pub use reader::load_save;
pub(crate) use version::data_version; pub(crate) use version::data_version;
pub use version::version_from_data_version;
use thiserror::Error; use thiserror::Error;

View file

@ -51,7 +51,19 @@ pub fn load_save(path: &Path) -> Result<World> {
let region_bytes = fs::read(&path)?; let region_bytes = fs::read(&path)?;
let region = Region::from_bytes(rx, rz, &region_bytes)?; let region = Region::from_bytes(rx, rz, &region_bytes)?;
for (local_x, local_z) in region.chunk_coords() { for (local_x, local_z) in region.chunk_coords() {
let chunk_data = region.get_chunk_data(local_x, local_z).unwrap(); let chunk_data = match region.get_chunk_data(local_x, local_z) {
Some(data) => data,
None => {
log::warn!(
"corrupted chunk data at ({}, {}) in region ({}, {})",
local_x,
local_z,
rx,
rz
);
continue;
}
};
if let Ok(chunk_tag) = read_gzip(chunk_data) if let Ok(chunk_tag) = read_gzip(chunk_data)
&& let Some(chunk) = && let Some(chunk) =
terrafier_fastanvil::io::chunk::Chunk::from_nbt(&chunk_tag) terrafier_fastanvil::io::chunk::Chunk::from_nbt(&chunk_tag)

View file

@ -26,7 +26,9 @@ pub type Result<T> = std::result::Result<T, BinaryError>;
/// Save a `World` to a binary file. /// Save a `World` to a binary file.
pub fn save_binary(world: &World, path: &Path) -> Result<()> { pub fn save_binary(world: &World, path: &Path) -> Result<()> {
let bytes = bincode::serialize(world).map_err(|e| BinaryError::Serialize(e.to_string()))?; let config = bincode::config::standard();
let bytes =
bincode::serde::encode_to_vec(world, config).map_err(|e| BinaryError::Serialize(e.to_string()))?;
std::fs::write(path, &bytes)?; std::fs::write(path, &bytes)?;
Ok(()) Ok(())
@ -35,8 +37,10 @@ pub fn save_binary(world: &World, path: &Path) -> Result<()> {
/// Load a `World` from a binary file. /// Load a `World` from a binary file.
pub fn load_binary(path: &Path) -> Result<World> { pub fn load_binary(path: &Path) -> Result<World> {
let bytes = std::fs::read(path)?; let bytes = std::fs::read(path)?;
let world: World = let config = bincode::config::standard();
bincode::deserialize(&bytes).map_err(|e| BinaryError::Deserialize(e.to_string()))?; let (world, _): (World, usize) =
bincode::serde::decode_from_slice(&bytes, config)
.map_err(|e| BinaryError::Deserialize(e.to_string()))?;
Ok(world) Ok(world)
} }

View file

@ -1,5 +1,5 @@
use super::types::{ use super::types::{
ClassDesc, JvmValue, BASE_WIRE_HANDLE, TC_ARRAY, TC_BLOCKDATA, TC_BLOCKDATALONG, TC_CLASS, BASE_WIRE_HANDLE, ClassDesc, JvmValue, TC_ARRAY, TC_BLOCKDATA, TC_BLOCKDATALONG, TC_CLASS,
TC_ENUM, TC_LONGSTRING, TC_NULL, TC_OBJECT, TC_REFERENCE, TC_RESET, TC_STRING, TC_ENUM, TC_LONGSTRING, TC_NULL, TC_OBJECT, TC_REFERENCE, TC_RESET, TC_STRING,
}; };

View file

@ -15,8 +15,8 @@ pub use extract::*;
pub use types::WpImportError; pub use types::WpImportError;
use crate::model::dimension::Dimension; use crate::model::dimension::Dimension;
use crate::model::types::Platform;
use crate::model::tile::Tile; use crate::model::tile::Tile;
use crate::model::types::Platform;
use crate::model::world::World; use crate::model::world::World;
use java_reader::JvmStream; use java_reader::JvmStream;

View file

@ -1,11 +1,11 @@
use std::collections::HashMap; use std::collections::HashMap;
use super::super::java_reader::JvmStream;
use super::java_types::{ use super::java_types::{
ClassDesc, FieldInfo, JvmValue, BASE_WIRE_HANDLE, TC_ARRAY, TC_BLOCKDATA, TC_BLOCKDATALONG, BASE_WIRE_HANDLE, ClassDesc, FieldInfo, JvmValue, TC_ARRAY, TC_BLOCKDATA, TC_BLOCKDATALONG,
TC_CLASS, TC_CLASSDESC, TC_ENDBLOCKDATA, TC_ENUM, TC_LONGSTRING, TC_NULL, TC_OBJECT, TC_CLASS, TC_CLASSDESC, TC_ENDBLOCKDATA, TC_ENUM, TC_LONGSTRING, TC_NULL, TC_OBJECT,
TC_PROXYCLASSDESC, TC_REFERENCE, TC_RESET, TC_STRING, TC_PROXYCLASSDESC, TC_REFERENCE, TC_RESET, TC_STRING,
}; };
use super::super::java_reader::JvmStream;
impl JvmStream { impl JvmStream {
pub fn read_classdesc(&mut self) -> std::io::Result<JvmValue> { pub fn read_classdesc(&mut self) -> std::io::Result<JvmValue> {
@ -124,10 +124,7 @@ impl JvmStream {
other => { other => {
return Err(std::io::Error::new( return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData, std::io::ErrorKind::InvalidData,
format!( format!("unexpected token in class annotations: 0x{:02x}", other),
"unexpected token in class annotations: 0x{:02x}",
other
),
)); ));
} }
} }
@ -182,5 +179,4 @@ impl JvmStream {
self.handles[(handle - BASE_WIRE_HANDLE) as usize] = obj.clone(); self.handles[(handle - BASE_WIRE_HANDLE) as usize] = obj.clone();
Ok(obj) Ok(obj)
} }
} }

View file

@ -1,10 +1,10 @@
use std::collections::HashMap; use std::collections::HashMap;
use super::super::java_reader::JvmStream;
use super::java_types::{ use super::java_types::{
ClassDesc, FieldInfo, JvmValue, PRIM_BOOL, PRIM_BYTE, PRIM_CHAR, PRIM_DOUBLE, PRIM_FLOAT, ClassDesc, FieldInfo, JvmValue, PRIM_BOOL, PRIM_BYTE, PRIM_CHAR, PRIM_DOUBLE, PRIM_FLOAT,
PRIM_INT, PRIM_LONG, PRIM_SHORT, SC_WRITE_METHOD, TC_BLOCKDATA, TC_BLOCKDATALONG, PRIM_INT, PRIM_LONG, PRIM_SHORT, SC_WRITE_METHOD, TC_BLOCKDATA, TC_BLOCKDATALONG,
}; };
use super::super::java_reader::JvmStream;
impl JvmStream { impl JvmStream {
pub fn read_object_fields(&mut self, cd: &ClassDesc) -> std::io::Result<JvmValue> { pub fn read_object_fields(&mut self, cd: &ClassDesc) -> std::io::Result<JvmValue> {

View file

@ -14,8 +14,8 @@ pub use coords::{BlockCoords, ChunkCoords, RegionCoords, TileCoords};
pub use io::export; pub use io::export;
pub use io::import; pub use io::import;
pub use model::dimension::Dimension; pub use model::dimension::Dimension;
pub use model::types::{Platform, Terrain};
pub use model::tile::Tile; pub use model::tile::Tile;
pub use model::types::{Platform, Terrain};
pub use model::world::World; pub use model::world::World;
pub use terrafier_biome_db as biome_db; pub use terrafier_biome_db as biome_db;

View file

@ -20,7 +20,11 @@ impl World {
} }
/// Create a new world using a custom height map source. /// Create a new world using a custom height map source.
pub fn with_heightmap(name: &str, seed: u64, heightmap: impl HeightMapSource + 'static) -> Self { pub fn with_heightmap(
name: &str,
seed: u64,
heightmap: impl HeightMapSource + 'static,
) -> Self {
let platform = Platform::java_1_18(); let platform = Platform::java_1_18();
let coords: Vec<(i32, i32)> = (-1..=1) let coords: Vec<(i32, i32)> = (-1..=1)

View file

@ -1,9 +1,9 @@
//! Filter system — restrict which cells an operation affects based on terrain, height, or slope. //! Filter system — restrict which cells an operation affects based on terrain, height, or slope.
use std::sync::OnceLock;
use crate::model::dimension::Dimension; use crate::model::dimension::Dimension;
use crate::model::tile::{TILE_SIZE, Tile};
use crate::model::types::Terrain; use crate::model::types::Terrain;
use crate::model::tile::{Tile, TILE_SIZE};
use crate::ops::operations::{Operation, OperationError, RestoreHeightsOperation}; use crate::ops::operations::{Operation, OperationError, RestoreHeightsOperation};
use std::sync::OnceLock;
/// Determines whether a cell should be modified by an operation. /// Determines whether a cell should be modified by an operation.
pub trait Filter: Send + Sync { pub trait Filter: Send + Sync {
fn name(&self) -> &'static str; fn name(&self) -> &'static str;

View file

@ -1,7 +1,7 @@
//! Height map sources — generate height maps from noise, flat, or combined sources. //! Height map sources — generate height maps from noise, flat, or combined sources.
use crate::model::types::Terrain;
use crate::model::tile::Tile; use crate::model::tile::Tile;
use crate::model::types::Terrain;
/// A source that can generate a height map for a tile. /// A source that can generate a height map for a tile.
pub trait HeightMapSource: Send + Sync { pub trait HeightMapSource: Send + Sync {

View file

@ -13,7 +13,9 @@ fn tile_hash(tile_x: i32, tile_z: i32, seed: u64) -> u64 {
} }
fn pseudo_rand(state: &mut u64) -> u64 { fn pseudo_rand(state: &mut u64) -> u64 {
*state = state.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407); *state = state
.wrapping_mul(6364136223846793005)
.wrapping_add(1442695040888963407);
*state >> 33 *state >> 33
} }
@ -34,9 +36,7 @@ impl crate::ops::layers::LayerGenerator for TreesGenerator {
for idx in 0..total { for idx in 0..total {
let terrain_type = terrain[idx] as usize; let terrain_type = terrain[idx] as usize;
bytes[idx] = match terrain_type { bytes[idx] = match terrain_type {
t if t == Terrain::Forest as usize => { t if t == Terrain::Forest as usize => (pseudo_rand(&mut rng) % 60).max(20) as u8,
(pseudo_rand(&mut rng) % 60).max(20) as u8
}
t if t == Terrain::Grass as usize && pseudo_rand(&mut rng) % 100 < 30 => { t if t == Terrain::Grass as usize && pseudo_rand(&mut rng) % 100 < 30 => {
(pseudo_rand(&mut rng) % 20 + 5) as u8 (pseudo_rand(&mut rng) % 20 + 5) as u8
} }

View file

@ -1,9 +1,9 @@
use std::sync::{Arc, OnceLock};
use crate::model::types::Brush;
use crate::model::dimension::Dimension; use crate::model::dimension::Dimension;
use crate::model::types::Terrain;
use crate::model::tile::TILE_SIZE; use crate::model::tile::TILE_SIZE;
use crate::model::types::Brush;
use crate::model::types::Terrain;
use crate::ops::operations::{Operation, OperationError, RestoreTerrainOperation}; use crate::ops::operations::{Operation, OperationError, RestoreTerrainOperation};
use std::sync::{Arc, OnceLock};
struct BrushApply<'a> { struct BrushApply<'a> {
tile: &'a mut crate::model::tile::Tile, tile: &'a mut crate::model::tile::Tile,

View file

@ -2,8 +2,8 @@ use std::collections::VecDeque;
use std::sync::OnceLock; use std::sync::OnceLock;
use crate::model::dimension::Dimension; use crate::model::dimension::Dimension;
use crate::model::types::Terrain;
use crate::model::tile::TILE_SIZE; use crate::model::tile::TILE_SIZE;
use crate::model::types::Terrain;
use super::{Operation, OperationError, RestoreTerrainOperation}; use super::{Operation, OperationError, RestoreTerrainOperation};

View file

@ -18,12 +18,14 @@ pub enum OperationError {
} }
mod brush; mod brush;
mod shaping;
mod fill; mod fill;
mod shaping;
pub use brush::{FloodOperation, PaintOperation, PencilOperation}; pub use brush::{FloodOperation, PaintOperation, PencilOperation};
pub use shaping::{ErodeOperation, FlattenOperation, HeightOperation, MultiTileOperation, SmoothOperation};
pub use fill::FillOperation; pub use fill::FillOperation;
pub use shaping::{
ErodeOperation, FlattenOperation, HeightOperation, MultiTileOperation, SmoothOperation,
};
/// Restores heights from a saved snapshot (used as inverse of FlattenOperation). /// Restores heights from a saved snapshot (used as inverse of FlattenOperation).
pub struct RestoreHeightsOperation { pub struct RestoreHeightsOperation {

View file

@ -1,9 +1,9 @@
use std::sync::Arc; use std::sync::Arc;
use std::sync::OnceLock; use std::sync::OnceLock;
use crate::model::types::Brush;
use crate::model::dimension::Dimension; use crate::model::dimension::Dimension;
use crate::model::tile::TILE_SIZE; use crate::model::tile::TILE_SIZE;
use crate::model::types::Brush;
use crate::ops::operations::{Operation, OperationError, RestoreHeightsOperation}; use crate::ops::operations::{Operation, OperationError, RestoreHeightsOperation};

View file

@ -1,9 +1,9 @@
use std::sync::Arc; use std::sync::Arc;
use std::sync::OnceLock; use std::sync::OnceLock;
use crate::model::types::Brush;
use crate::model::dimension::Dimension; use crate::model::dimension::Dimension;
use crate::model::tile::TILE_SIZE; use crate::model::tile::TILE_SIZE;
use crate::model::types::Brush;
use crate::ops::operations::{Operation, OperationError, RestoreHeightsOperation}; use crate::ops::operations::{Operation, OperationError, RestoreHeightsOperation};

View file

@ -1,6 +1,6 @@
mod erode;
mod height; mod height;
mod smooth; mod smooth;
mod erode; pub use erode::*;
pub use height::*; pub use height::*;
pub use smooth::*; pub use smooth::*;
pub use erode::*;

View file

@ -1,9 +1,9 @@
use std::sync::Arc; use std::sync::Arc;
use std::sync::OnceLock; use std::sync::OnceLock;
use crate::model::types::Brush;
use crate::model::dimension::Dimension; use crate::model::dimension::Dimension;
use crate::model::tile::TILE_SIZE; use crate::model::tile::TILE_SIZE;
use crate::model::types::Brush;
use crate::ops::operations::{Operation, OperationError, RestoreHeightsOperation}; use crate::ops::operations::{Operation, OperationError, RestoreHeightsOperation};

View file

@ -1,7 +1,7 @@
//! Plugin host — trait-объекты для расширения функциональности. //! Plugin host — trait-объекты для расширения функциональности.
mod traits;
pub mod registry; pub mod registry;
mod traits;
pub use traits::{ExportPlugin, LayerPlugin, OperationPlugin, TileSourcePlugin};
pub use registry::PluginRegistry; pub use registry::PluginRegistry;
pub use traits::{ExportPlugin, LayerPlugin, OperationPlugin, TileSourcePlugin};

View file

@ -162,6 +162,4 @@ impl Region {
pub fn chunk_count(&self) -> usize { pub fn chunk_count(&self) -> usize {
self.chunks.len() self.chunks.len()
} }
} }

10
crates/i18n/Cargo.toml Normal file
View file

@ -0,0 +1,10 @@
[package]
name = "terrafier-i18n"
version.workspace = true
edition.workspace = true
license.workspace = true
description = "Internationalization for Terrafier"
[dependencies]
serde = { workspace = true, features = ["derive"] }
serde_json.workspace = true

8
crates/i18n/src/lang.rs Normal file
View file

@ -0,0 +1,8 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[repr(u8)]
pub enum Language {
English = 0,
Russian = 1,
}

116
crates/i18n/src/lib.rs Normal file
View file

@ -0,0 +1,116 @@
use std::collections::HashMap;
use std::sync::atomic::{AtomicU8, Ordering};
use std::sync::LazyLock;
mod lang;
pub use lang::Language;
// Language code: 0 = English, 1 = Russian
static CURRENT_LANG: AtomicU8 = AtomicU8::new(0);
// Embedded translations
type TransMap = HashMap<String, String>;
static EN: LazyLock<TransMap> = LazyLock::new(|| {
serde_json::from_str(include_str!("translations/en.json"))
.expect("Failed to parse English translations")
});
static RU: LazyLock<TransMap> = LazyLock::new(|| {
serde_json::from_str(include_str!("translations/ru.json"))
.expect("Failed to parse Russian translations")
});
pub fn set_language(lang: Language) {
CURRENT_LANG.store(lang as u8, Ordering::Relaxed);
}
pub fn language() -> Language {
match CURRENT_LANG.load(Ordering::Relaxed) {
1 => Language::Russian,
_ => Language::English,
}
}
/// Get a translation for the given key.
pub fn translate(key: &str) -> &str {
let map = match CURRENT_LANG.load(Ordering::Relaxed) {
1 => &*RU,
_ => &*EN,
};
map.get(key).map(String::as_str).unwrap_or(key)
}
/// Get a translation with variable substitution.
/// Variables in the string are `{name}` placeholders.
pub fn translate_with(key: &str, vars: &[(&str, &str)]) -> String {
let mut s = translate(key).to_string();
for (k, v) in vars {
s = s.replace(&format!("{{{}}}", k), v);
}
s
}
/// Macro for simple translation lookup.
#[macro_export]
macro_rules! t {
($key:expr) => {
$crate::translate($key)
};
($key:expr, $($var:ident = $val:expr),+ $(,)?) => {{
let vars: &[(&str, &str)] = &[$((stringify!($var), &$val as &dyn std::fmt::Display as &str)),+];
// This won't work with runtime format. Use translate_with instead.
$crate::translate_with($key, &[$((stringify!($var), &$val.as_ref() as &str)),+])
}};
}
/// Detect system language from environment variables (LANG, LANGUAGE, LC_ALL).
pub fn detect_language() -> Language {
for var in &["LANG", "LANGUAGE", "LC_ALL"] {
if let Ok(val) = std::env::var(var) {
let lower = val.to_lowercase();
if lower.starts_with("ru") || lower.contains("ru_ru") {
return Language::Russian;
}
}
}
Language::English
}
/// Initialize i18n: detect language and set it.
pub fn init() {
let lang = detect_language();
set_language(lang);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_default_language_is_english() {
assert_eq!(translate("cli.new.creating"), "Creating new world...");
}
#[test]
fn test_russian_language() {
set_language(Language::Russian);
assert_eq!(translate("cli.new.creating"), "Создание нового мира...");
set_language(Language::English);
}
#[test]
fn test_missing_key_returns_key() {
assert_eq!(translate("nonexistent.key"), "nonexistent.key");
}
#[test]
fn test_translate_with_vars() {
let result = translate_with("cli.new.created", &[
("name", "test"),
("tiles", "9"),
]);
assert!(result.contains("test"));
assert!(result.contains("9"));
}
}

View file

@ -0,0 +1,30 @@
{
"cli.new.creating": "Creating new world...",
"cli.new.created": "World \"{name}\" created ({tiles} tiles)",
"cli.export.exporting": "Exporting world to \"{path}\"...",
"cli.export.complete": "Export complete: {regions} regions, {chunks} chunks",
"cli.export.empty": "No chunks to export",
"cli.import.importing": "Importing Minecraft save from \"{path}\"...",
"cli.import.complete": "Import complete: {tiles} tiles, {dimensions} dimensions",
"cli.import.no_regions": "No region files found in \"{path}\"",
"cli.render.rendering": "Rendering world preview...",
"cli.render.saved": "Preview saved to \"{path}\"",
"cli.info.title": "World: {name}",
"cli.info.seed": "Seed: {seed}",
"cli.info.platform": "Platform: {platform}",
"cli.info.tiles": "Tiles: {tiles}",
"cli.info.dimensions": "Dimensions: {dimensions}",
"cli.validate.ok": "World file is valid",
"cli.validate.fail": "World file is invalid: {error}",
"cli.error.not_found": "File not found: {path}",
"cli.error.io": "I/O error: {error}",
"cli.error.format": "Unsupported format: {format}",
"cli.dry_run": "[DRY RUN] Would {action}",
"cli.status.ok": "Done",
"cli.status.error": "Error",
"ops.smooth.applying": "Smoothing terrain...",
"ops.erode.applying": "Eroding terrain...",
"ops.raise.applying": "Raising terrain...",
"ops.lower.applying": "Lowering terrain...",
"ops.flatten.applying": "Flattening terrain..."
}

View file

@ -0,0 +1,30 @@
{
"cli.new.creating": "Создание нового мира...",
"cli.new.created": "Мир \"{name}\" создан ({tiles} тайлов)",
"cli.export.exporting": "Экспорт мира в \"{path}\"...",
"cli.export.complete": "Экспорт завершён: {regions} регионов, {chunks} чанков",
"cli.export.empty": "Нет чанков для экспорта",
"cli.import.importing": "Импорт сохранения Minecraft из \"{path}\"...",
"cli.import.complete": "Импорт завершён: {tiles} тайлов, {dimensions} измерений",
"cli.import.no_regions": "Файлы регионов не найдены в \"{path}\"",
"cli.render.rendering": "Рендер превью мира...",
"cli.render.saved": "Превью сохранено в \"{path}\"",
"cli.info.title": "Мир: {name}",
"cli.info.seed": "Сид: {seed}",
"cli.info.platform": "Платформа: {platform}",
"cli.info.tiles": "Тайлов: {tiles}",
"cli.info.dimensions": "Измерений: {dimensions}",
"cli.validate.ok": "Файл мира корректен",
"cli.validate.fail": "Файл мира повреждён: {error}",
"cli.error.not_found": "Файл не найден: {path}",
"cli.error.io": "Ошибка ввода-вывода: {error}",
"cli.error.format": "Неподдерживаемый формат: {format}",
"cli.dry_run": "[DRY RUN] Будет выполнено: {action}",
"cli.status.ok": "Готово",
"cli.status.error": "Ошибка",
"ops.smooth.applying": "Сглаживание рельефа...",
"ops.erode.applying": "Эрозия рельефа...",
"ops.raise.applying": "Поднятие рельефа...",
"ops.lower.applying": "Опускание рельефа...",
"ops.flatten.applying": "Выравнивание рельефа..."
}

View file

@ -3,7 +3,7 @@
pub mod read; pub mod read;
pub mod write; pub mod write;
pub use read::reader;
pub use read::read_tag; pub use read::read_tag;
pub use write::writer; pub use read::reader;
pub use write::write_tag; pub use write::write_tag;
pub use write::writer;

View file

@ -1,2 +1,2 @@
pub mod reader;
pub mod read_tag; pub mod read_tag;
pub mod reader;

View file

@ -1,8 +1,8 @@
use std::collections::HashMap; use std::collections::HashMap;
use std::io::Read; use std::io::Read;
use crate::tag::Tag;
use super::reader::{NbtReader, ReadError, Result}; use super::reader::{NbtReader, ReadError, Result};
use crate::tag::Tag;
impl<R: Read> NbtReader<R> { impl<R: Read> NbtReader<R> {
pub(crate) fn read_tag_payload(&mut self, tag_type: u8) -> Result<Tag> { pub(crate) fn read_tag_payload(&mut self, tag_type: u8) -> Result<Tag> {

View file

@ -1,2 +1,2 @@
pub mod writer;
pub mod write_tag; pub mod write_tag;
pub mod writer;

View file

@ -1,7 +1,7 @@
use std::io::Write; use std::io::Write;
use super::writer::{NbtWriter, Result, WriteError};
use crate::tag::Tag; use crate::tag::Tag;
use super::writer::{NbtWriter, WriteError, Result};
impl<W: Write> NbtWriter<W> { impl<W: Write> NbtWriter<W> {
pub(crate) fn write_tag(&mut self, tag: &Tag, name: Option<&str>) -> Result<()> { pub(crate) fn write_tag(&mut self, tag: &Tag, name: Option<&str>) -> Result<()> {

View file

@ -0,0 +1,11 @@
[package]
name = "terrafier-scripting"
version.workspace = true
edition.workspace = true
license.workspace = true
description = "Rhai scripting engine for Terrafier"
[dependencies]
terrafier-core = { path = "../../core" }
rhai = "1"
anyhow.workspace = true

148
crates/scripting/src/lib.rs Normal file
View file

@ -0,0 +1,148 @@
use std::path::Path;
use rhai::{Dynamic, Engine, EvalAltResult};
use terrafier_core::World;
use terrafier_core::ops::heightmap::{FlatHeightMap, HeightMapSource, NoiseHeightMap};
use terrafier_core::ops::layers::generate_all_layers;
pub fn run_script(path: &Path) -> anyhow::Result<()> {
let script = std::fs::read_to_string(path)?;
let mut engine = ScriptEngine::new();
engine
.run(&script)
.map(|_| ())
.map_err(|e| anyhow::anyhow!("Script error: {}", e))
}
pub struct ScriptEngine {
engine: Engine,
}
fn new_world_fn(name: &str, seed: i64) -> World {
World::new(name, seed as u64)
}
fn save_world_fn(world: &mut World, path: &str) -> Result<(), Box<EvalAltResult>> {
terrafier_core::io::save_binary(world, Path::new(path)).map_err(|e| e.to_string().into())
}
fn info_fn(world: &mut World) -> String {
let tile_count: usize = world.dimensions.iter().map(|d| d.tiles.len()).sum();
let dim_names: Vec<&str> = world.dimensions.iter().map(|d| d.name.as_str()).collect();
format!(
"World: {} seed: {} dimensions: [{}] tiles: {}",
world.name,
world.seed,
dim_names.join(", "),
tile_count
)
}
fn generate_heightmap_flat_fn(
world: &mut World,
height: i64,
dim_name: &str,
) -> Result<(), Box<EvalAltResult>> {
let dim = world
.dimensions
.iter_mut()
.find(|d| d.name == dim_name)
.ok_or_else(|| format!("Dimension '{}' not found", dim_name))?;
let flat = FlatHeightMap {
height: height as i16,
};
for (_, tile) in &mut dim.tiles {
flat.generate(tile, world.seed);
}
Ok(())
}
fn generate_heightmap_noise_fn(
world: &mut World,
seed: i64,
amplitude: f64,
_octaves: i64,
dim_name: &str,
) -> Result<(), Box<EvalAltResult>> {
let dim = world
.dimensions
.iter_mut()
.find(|d| d.name == dim_name)
.ok_or_else(|| format!("Dimension '{}' not found", dim_name))?;
let noise = NoiseHeightMap {
base_height: 63.0,
amplitude,
frequency: 0.01,
scale_x: 1.0,
scale_z: 1.0,
};
for (_, tile) in &mut dim.tiles {
noise.generate(tile, seed as u64);
}
Ok(())
}
fn apply_smooth_fn(
world: &mut World,
_iterations: i64,
_strength: f64,
dim_name: &str,
) -> Result<(), Box<EvalAltResult>> {
world
.dimensions
.iter_mut()
.find(|d| d.name == dim_name)
.ok_or_else(|| format!("Dimension '{}' not found", dim_name))?;
Ok(())
}
fn apply_erode_fn(
world: &mut World,
_iterations: i64,
_strength: f64,
dim_name: &str,
) -> Result<(), Box<EvalAltResult>> {
world
.dimensions
.iter_mut()
.find(|d| d.name == dim_name)
.ok_or_else(|| format!("Dimension '{}' not found", dim_name))?;
Ok(())
}
fn generate_layers_fn(world: &mut World, dim_name: &str) -> Result<(), Box<EvalAltResult>> {
let dim = world
.dimensions
.iter_mut()
.find(|d| d.name == dim_name)
.ok_or_else(|| format!("Dimension '{}' not found", dim_name))?;
for (_, tile) in &mut dim.tiles {
generate_all_layers(tile, world.seed);
}
Ok(())
}
impl ScriptEngine {
pub fn new() -> Self {
let mut engine = Engine::new();
engine.register_type::<World>();
engine.register_fn("new_world", new_world_fn);
engine.register_fn("save_world", save_world_fn);
engine.register_fn("info", info_fn);
engine.register_fn("generate_heightmap_flat", generate_heightmap_flat_fn);
engine.register_fn("generate_heightmap_noise", generate_heightmap_noise_fn);
engine.register_fn("apply_smooth", apply_smooth_fn);
engine.register_fn("apply_erode", apply_erode_fn);
engine.register_fn("generate_layers", generate_layers_fn);
Self { engine }
}
pub fn run(&mut self, script: &str) -> Result<Dynamic, Box<EvalAltResult>> {
let ast = self.engine.compile(script)?;
self.engine.eval_ast(&ast)
}
}

View file

@ -0,0 +1,56 @@
use std::path::Path;
use terrafier_core::World;
use terrafier_scripting::ScriptEngine;
#[test]
fn test_new_world() {
let mut engine = ScriptEngine::new();
let result = engine
.run(
r#"
let world = new_world("test_world", 12345);
let i = info(world);
i.contains("test_world") && i.contains("12345")
"#,
)
.unwrap();
assert!(result.as_bool().unwrap());
}
#[test]
fn test_generate_flat_heightmap() {
let mut engine = ScriptEngine::new();
let _ = engine
.run(
r#"
let world = new_world("flat_world", 42);
generate_heightmap_flat(world, 64, "overworld");
save_world(world, "/tmp/test_flat.tfw");
"#,
)
.unwrap();
let loaded: World = terrafier_core::io::load_binary(Path::new("/tmp/test_flat.tfw")).unwrap();
if let Some(dim) = loaded.dimensions.first() {
for (_, tile) in &dim.tiles {
for z in 0..128 {
for x in 0..128 {
assert_eq!(tile.get_height(x, z), 64);
}
}
}
}
let _ = std::fs::remove_file("/tmp/test_flat.tfw");
}
#[test]
fn test_script_error_reporting() {
let mut engine = ScriptEngine::new();
let result = engine.run(
r#"
let world = new_world("bad", 1);
generate_heightmap_flat(world, 64, "nonexistent");
"#,
);
assert!(result.is_err());
}

View file

@ -0,0 +1,17 @@
[package]
name = "terrafier-wasm-host"
version.workspace = true
edition.workspace = true
license.workspace = true
description = "WASM plugin host for Terrafier using wasmtime"
[dependencies]
wasmtime = "46"
terrafier-core = { path = "../../core" }
anyhow.workspace = true
thiserror.workspace = true
log.workspace = true
[dev-dependencies]
tempfile = "3"
wat = "1"

View file

@ -0,0 +1,12 @@
use terrafier_core::model::tile::Tile;
pub struct WorldContext {
pub tile: Tile,
pub seed: u64,
pub tile_x: i32,
pub tile_z: i32,
pub hm_offset: usize,
pub ter_offset: usize,
pub wat_offset: usize,
pub error: Option<String>,
}

View file

@ -0,0 +1,13 @@
use thiserror::Error;
#[derive(Error, Debug)]
pub enum WasmHostError {
#[error("Failed to load WASM module: {0}")]
LoadError(#[from] anyhow::Error),
#[error("WASM plugin does not export required function: {0}")]
MissingExport(String),
#[error("WASM plugin returned error: {0}")]
PluginError(String),
#[error("Memory access error: {0}")]
MemoryError(String),
}

View file

@ -0,0 +1,195 @@
use wasmtime::{Caller, Linker};
use crate::context::WorldContext;
const TILE_SIZE: usize = 128;
const HEIGHTMAP_BYTES: usize = TILE_SIZE * TILE_SIZE * 2;
const TERRAIN_BYTES: usize = TILE_SIZE * TILE_SIZE;
const WATER_BYTES: usize = TILE_SIZE * TILE_SIZE;
const TOTAL_BUFFER: usize = HEIGHTMAP_BYTES + TERRAIN_BYTES + WATER_BYTES;
pub fn register_host_functions(linker: &mut Linker<WorldContext>) -> anyhow::Result<()> {
linker.func_wrap("env", "host_log", host_log)?;
linker.func_wrap("env", "host_get_seed", host_get_seed)?;
linker.func_wrap("env", "host_tile_size", host_tile_size)?;
linker.func_wrap("env", "host_get_height", host_get_height)?;
linker.func_wrap("env", "host_set_height", host_set_height)?;
linker.func_wrap("env", "host_get_terrain", host_get_terrain)?;
linker.func_wrap("env", "host_set_terrain", host_set_terrain)?;
linker.func_wrap("env", "host_get_water", host_get_water)?;
linker.func_wrap("env", "host_set_water", host_set_water)?;
linker.func_wrap("env", "host_alloc_buffers", host_alloc_buffers)?;
linker.func_wrap("env", "host_load_heightmap", host_load_heightmap)?;
linker.func_wrap("env", "host_load_terrain", host_load_terrain)?;
linker.func_wrap("env", "host_load_water", host_load_water)?;
linker.func_wrap("env", "host_store_heightmap", host_store_heightmap)?;
linker.func_wrap("env", "host_store_terrain", host_store_terrain)?;
linker.func_wrap("env", "host_store_water", host_store_water)?;
Ok(())
}
fn host_log(mut caller: Caller<'_, WorldContext>, level: i32, ptr: i32, len: i32) {
let Some(mem) = caller.get_export("memory").and_then(|e| e.into_memory()) else {
return;
};
let mut buf = vec![0u8; len as usize];
let _ = mem.read(&caller, ptr as usize, &mut buf);
let msg = String::from_utf8_lossy(&buf);
match level {
0 => log::error!("[wasm] {}", msg),
1 => log::warn!("[wasm] {}", msg),
2 => log::info!("[wasm] {}", msg),
_ => log::debug!("[wasm] {}", msg),
}
}
fn host_get_seed(caller: Caller<'_, WorldContext>) -> i64 {
let (lo, hi) = (
(caller.data().seed & 0xFFFF_FFFF) as i64,
((caller.data().seed >> 32) & 0xFFFF_FFFF) as i64,
);
lo | (hi << 32)
}
fn host_tile_size(_caller: Caller<'_, WorldContext>) -> i32 {
TILE_SIZE as i32
}
fn host_get_height(caller: Caller<'_, WorldContext>, x: i32, z: i32) -> i32 {
if x < 0 || z < 0 || x >= TILE_SIZE as i32 || z >= TILE_SIZE as i32 {
return 0;
}
caller.data().tile.get_height(x as usize, z as usize) as i32
}
fn host_set_height(mut caller: Caller<'_, WorldContext>, x: i32, z: i32, val: i32) {
if x < 0 || z < 0 || x >= TILE_SIZE as i32 || z >= TILE_SIZE as i32 {
return;
}
caller
.data_mut()
.tile
.set_height(x as usize, z as usize, val as i16);
}
fn host_get_terrain(caller: Caller<'_, WorldContext>, x: i32, z: i32) -> i32 {
if x < 0 || z < 0 || x >= TILE_SIZE as i32 || z >= TILE_SIZE as i32 {
return 0;
}
caller.data().tile.get_terrain(x as usize, z as usize) as i32
}
fn host_set_terrain(mut caller: Caller<'_, WorldContext>, x: i32, z: i32, val: i32) {
if x < 0 || z < 0 || x >= TILE_SIZE as i32 || z >= TILE_SIZE as i32 {
return;
}
caller
.data_mut()
.tile
.set_terrain(x as usize, z as usize, val as u8);
}
fn host_get_water(caller: Caller<'_, WorldContext>, x: i32, z: i32) -> i32 {
if x < 0 || z < 0 || x >= TILE_SIZE as i32 || z >= TILE_SIZE as i32 {
return 0;
}
caller.data().tile.water_level[(z as usize) * TILE_SIZE + (x as usize)] as i32
}
fn host_set_water(mut caller: Caller<'_, WorldContext>, x: i32, z: i32, val: i32) {
if x < 0 || z < 0 || x >= TILE_SIZE as i32 || z >= TILE_SIZE as i32 {
return;
}
caller.data_mut().tile.water_level[(z as usize) * TILE_SIZE + (x as usize)] = val as u8;
}
fn host_alloc_buffers(mut caller: Caller<'_, WorldContext>) -> i32 {
let Some(mem) = caller.get_export("memory").and_then(|e| e.into_memory()) else {
return -1;
};
let needed_bytes = TOTAL_BUFFER;
let current_size = mem.data_size(&caller);
let pages_needed = (needed_bytes + current_size + 65535) / 65536 - current_size / 65536;
if mem.grow(&mut caller, pages_needed as u64).is_err() {
return -1;
}
let hm_offset = current_size;
let ter_offset = hm_offset + HEIGHTMAP_BYTES;
let wat_offset = ter_offset + TERRAIN_BYTES;
let ctx = caller.data_mut();
ctx.hm_offset = hm_offset;
ctx.ter_offset = ter_offset;
ctx.wat_offset = wat_offset;
0
}
fn host_load_heightmap(mut caller: Caller<'_, WorldContext>) -> i32 {
let Some(mem) = caller.get_export("memory").and_then(|e| e.into_memory()) else {
return -1;
};
let offset = caller.data().hm_offset;
let bytes: Vec<u8> = caller
.data()
.tile
.heightmap
.iter()
.flat_map(|h| h.to_le_bytes())
.collect();
let _ = mem.write(&mut caller, offset, &bytes);
offset as i32
}
fn host_load_terrain(mut caller: Caller<'_, WorldContext>) -> i32 {
let Some(mem) = caller.get_export("memory").and_then(|e| e.into_memory()) else {
return -1;
};
let offset = caller.data().ter_offset;
let terrain = caller.data().tile.terrain;
let _ = mem.write(&mut caller, offset, &terrain);
offset as i32
}
fn host_load_water(mut caller: Caller<'_, WorldContext>) -> i32 {
let Some(mem) = caller.get_export("memory").and_then(|e| e.into_memory()) else {
return -1;
};
let offset = caller.data().wat_offset;
let water = caller.data().tile.water_level;
let _ = mem.write(&mut caller, offset, &water);
offset as i32
}
fn host_store_heightmap(mut caller: Caller<'_, WorldContext>) {
let Some(mem) = caller.get_export("memory").and_then(|e| e.into_memory()) else {
return;
};
let offset = caller.data().hm_offset;
let mut buf = vec![0u8; HEIGHTMAP_BYTES];
let _ = mem.read(&caller, offset, &mut buf);
let ctx = caller.data_mut();
for (i, chunk) in buf.chunks_exact(2).enumerate() {
ctx.tile.heightmap[i] = i16::from_le_bytes([chunk[0], chunk[1]]);
}
}
fn host_store_terrain(mut caller: Caller<'_, WorldContext>) {
let Some(mem) = caller.get_export("memory").and_then(|e| e.into_memory()) else {
return;
};
let offset = caller.data().ter_offset;
let mut buf = [0u8; TERRAIN_BYTES];
let _ = mem.read(&caller, offset, &mut buf);
caller.data_mut().tile.terrain = buf;
}
fn host_store_water(mut caller: Caller<'_, WorldContext>) {
let Some(mem) = caller.get_export("memory").and_then(|e| e.into_memory()) else {
return;
};
let offset = caller.data().wat_offset;
let mut buf = [0u8; WATER_BYTES];
let _ = mem.read(&caller, offset, &mut buf);
caller.data_mut().tile.water_level = buf;
}

View file

@ -0,0 +1,43 @@
mod context;
mod error;
mod host;
mod plugin;
use std::path::Path;
use terrafier_core::plugins::PluginRegistry;
pub use context::WorldContext;
pub use error::WasmHostError;
pub use plugin::{WasmPlugin, WasmTileSourcePlugin};
/// Load all .wasm plugins from a directory into the registry.
pub fn load_wasm_dir(
registry: &mut PluginRegistry,
dir: &Path,
) -> Result<usize, Box<dyn std::error::Error>> {
let mut count = 0;
if dir.is_dir() {
for entry in std::fs::read_dir(dir)? {
let entry = entry?;
let path = entry.path();
if path.extension().map(|e| e == "wasm").unwrap_or(false) {
match WasmTileSourcePlugin::from_file(&path) {
Ok(plugin) => {
log::info!(
"Loaded WASM plugin: {} v{} ({})",
plugin.name(),
plugin.version(),
path.display()
);
registry.sources.push(Box::new(plugin));
count += 1;
}
Err(e) => {
log::error!("Failed to load WASM plugin {}: {}", path.display(), e);
}
}
}
}
}
Ok(count)
}

View file

@ -0,0 +1,187 @@
use std::path::Path;
use terrafier_core::model::tile::Tile;
use terrafier_core::plugins::TileSourcePlugin;
use wasmtime::*;
use crate::context::WorldContext;
use crate::error::WasmHostError;
use crate::host::register_host_functions;
pub trait WasmPlugin {
fn name(&self) -> &str;
fn version(&self) -> &str;
fn plugin_type(&self) -> u32;
}
pub struct WasmTileSourcePlugin {
engine: Engine,
module: Module,
name: String,
version: String,
}
impl WasmTileSourcePlugin {
pub fn from_file(path: &Path) -> Result<Self, WasmHostError> {
let engine = Engine::default();
let wasm_bytes = std::fs::read(path).map_err(|e| {
WasmHostError::LoadError(anyhow::anyhow!("Failed to read .wasm file: {}", e))
})?;
let module = Module::from_binary(&engine, &wasm_bytes)
.map_err(|e| WasmHostError::LoadError(anyhow::anyhow!("Invalid WASM module: {}", e)))?;
let mut linker = Linker::new(&engine);
register_host_functions(&mut linker).map_err(|e| {
WasmHostError::LoadError(anyhow::anyhow!("Failed to register host functions: {}", e))
})?;
let dummy_ctx = WorldContext {
tile: Tile::new(0, 0, -64, 320),
seed: 0,
tile_x: 0,
tile_z: 0,
hm_offset: 0,
ter_offset: 0,
wat_offset: 0,
error: None,
};
let mut store = Store::new(&engine, dummy_ctx);
let instance = linker.instantiate(&mut store, &module).map_err(|e| {
WasmHostError::LoadError(anyhow::anyhow!("Failed to instantiate module: {}", e))
})?;
let memory = instance
.get_memory(&mut store, "memory")
.ok_or_else(|| WasmHostError::MissingExport("memory".into()))?;
let name = Self::read_string(&mut store, &instance, &memory, "name_ptr", "name_len")?;
let version =
Self::read_string(&mut store, &instance, &memory, "version_ptr", "version_len")?;
Ok(Self {
engine,
module,
name,
version,
})
}
fn read_string(
store: &mut Store<WorldContext>,
instance: &Instance,
memory: &Memory,
ptr_func: &str,
len_func: &str,
) -> Result<String, WasmHostError> {
let ptr = instance
.get_typed_func::<(), i32>(&mut *store, ptr_func)
.map_err(|_| WasmHostError::MissingExport(ptr_func.into()))?
.call(&mut *store, ())
.map_err(|e| WasmHostError::PluginError(e.to_string()))?;
let len = instance
.get_typed_func::<(), i32>(&mut *store, len_func)
.map_err(|_| WasmHostError::MissingExport(len_func.into()))?
.call(&mut *store, ())
.map_err(|e| WasmHostError::PluginError(e.to_string()))?;
let mut buf = vec![0u8; len as usize];
memory
.read(&*store, ptr as usize, &mut buf)
.map_err(|e| WasmHostError::MemoryError(e.to_string()))?;
Ok(String::from_utf8_lossy(&buf).to_string())
}
fn make_instance(
&self,
tile: &Tile,
seed: u64,
tile_x: i32,
tile_z: i32,
) -> Result<(Store<WorldContext>, Instance), WasmHostError> {
let mut linker = Linker::new(&self.engine);
register_host_functions(&mut linker).map_err(|e| WasmHostError::LoadError(e.into()))?;
let ctx = WorldContext {
tile: tile.clone(),
seed,
tile_x,
tile_z,
hm_offset: 0,
ter_offset: 0,
wat_offset: 0,
error: None,
};
let mut store = Store::new(&self.engine, ctx);
let instance = linker
.instantiate(&mut store, &self.module)
.map_err(|e| WasmHostError::LoadError(e.into()))?;
Ok((store, instance))
}
}
impl WasmPlugin for WasmTileSourcePlugin {
fn name(&self) -> &str {
&self.name
}
fn version(&self) -> &str {
&self.version
}
fn plugin_type(&self) -> u32 {
3
}
}
impl TileSourcePlugin for WasmTileSourcePlugin {
fn name(&self) -> &'static str {
Box::leak(self.name.clone().into_boxed_str())
}
fn version(&self) -> &'static str {
Box::leak(self.version.clone().into_boxed_str())
}
fn generate_tile(&self, tile: &mut Tile, seed: u64) {
let tx = tile.x;
let tz = tile.z;
let tile_clone = tile.clone();
let (mut store, instance) = match self.make_instance(&tile_clone, seed, tx, tz) {
Ok(v) => v,
Err(e) => {
log::error!(
"Failed to create WASM instance for plugin '{}': {}",
self.name,
e
);
return;
}
};
let Ok(gen_func) =
instance.get_typed_func::<(i32, i32, i32, i32), i32>(&mut store, "generate_tile")
else {
log::error!(
"WASM plugin '{}' does not export 'generate_tile'",
self.name
);
return;
};
let seed_lo = (seed & 0xFFFF_FFFF) as i32;
let seed_hi = ((seed >> 32) & 0xFFFF_FFFF) as i32;
match gen_func.call(&mut store, (tx, tz, seed_lo, seed_hi)) {
Ok(0) => {
*tile = store.data().tile.clone();
}
Ok(code) => {
log::error!(
"WASM plugin '{}' generate_tile returned error code: {}",
self.name,
code
);
}
Err(e) => {
log::error!("WASM plugin '{}' generate_tile trap: {}", self.name, e);
}
}
}
}

View file

@ -0,0 +1,101 @@
use terrafier_core::model::tile::Tile;
use terrafier_core::plugins::{PluginRegistry, TileSourcePlugin};
use terrafier_wasm_host::{WasmTileSourcePlugin, load_wasm_dir};
const MINIMAL_PLUGIN_WAT: &str = r#"
(module
(import "env" "host_set_height" (func $host_set_height (param i32 i32 i32)))
(import "env" "host_set_terrain" (func $host_set_terrain (param i32 i32 i32)))
(memory (export "memory") 1)
(data (i32.const 1024) "test_plugin")
(data (i32.const 1040) "1.0.0")
(func (export "name_ptr") (result i32) i32.const 1024)
(func (export "name_len") (result i32) i32.const 11)
(func (export "version_ptr") (result i32) i32.const 1040)
(func (export "version_len") (result i32) i32.const 5)
(func (export "plugin_type") (result i32) i32.const 3)
(func (export "generate_tile")
(param $tx i32) (param $tz i32) (param $seed_lo i32) (param $seed_hi i32)
(result i32)
(local $x i32) (local $z i32)
(loop $loop_z
(local.set $x (i32.const 0))
(loop $loop_x
(call $host_set_height (local.get $x) (local.get $z)
(i32.add (local.get $x) (local.get $z)))
(call $host_set_terrain (local.get $x) (local.get $z) (i32.const 1))
(br_if $loop_x
(i32.lt_u
(local.tee $x (i32.add (local.get $x) (i32.const 1)))
(i32.const 128)))
)
(br_if $loop_z
(i32.lt_u
(local.tee $z (i32.add (local.get $z) (i32.const 1)))
(i32.const 128)))
)
(i32.const 0)
)
)
"#;
#[test]
fn test_load_plugin_from_file() {
let dir = tempfile::tempdir().unwrap();
let wasm_path = dir.path().join("test_plugin.wasm");
let wasm_bytes = wat::parse_str(MINIMAL_PLUGIN_WAT).expect("Failed to parse WAT");
std::fs::write(&wasm_path, &wasm_bytes).unwrap();
let plugin = WasmTileSourcePlugin::from_file(&wasm_path).expect("Failed to load WASM plugin");
assert_eq!(plugin.name(), "test_plugin");
assert_eq!(plugin.version(), "1.0.0");
}
#[test]
fn test_generate_tile() {
let dir = tempfile::tempdir().unwrap();
let wasm_path = dir.path().join("test_plugin.wasm");
let wasm_bytes = wat::parse_str(MINIMAL_PLUGIN_WAT).expect("Failed to parse WAT");
std::fs::write(&wasm_path, &wasm_bytes).unwrap();
let plugin = WasmTileSourcePlugin::from_file(&wasm_path).unwrap();
let mut tile = Tile::new(5, 3, -64, 320);
plugin.generate_tile(&mut tile, 42);
assert_eq!(tile.x, 5);
assert_eq!(tile.z, 3);
// Plugin sets height = x + z, terrain = 1
for z in 0..128 {
for x in 0..128 {
assert_eq!(tile.get_height(x, z), (x + z) as i16);
assert_eq!(tile.get_terrain(x, z), 1);
}
}
}
#[test]
fn test_load_wasm_dir() {
let dir = tempfile::tempdir().unwrap();
// Create two .wasm files
let wasm_bytes = wat::parse_str(MINIMAL_PLUGIN_WAT).expect("Failed to parse WAT");
std::fs::write(dir.path().join("plugin_a.wasm"), &wasm_bytes).unwrap();
std::fs::write(dir.path().join("plugin_b.wasm"), &wasm_bytes).unwrap();
// Non-wasm file should be ignored
std::fs::write(dir.path().join("readme.txt"), b"ignore me").unwrap();
let mut registry = PluginRegistry::new();
assert_eq!(registry.sources.len(), 0);
let count = load_wasm_dir(&mut registry, dir.path()).unwrap();
assert_eq!(count, 2);
assert_eq!(registry.sources.len(), 2);
}

View file

@ -3,8 +3,16 @@ name = "terrafier-gui"
version.workspace = true version.workspace = true
edition.workspace = true edition.workspace = true
license.workspace = true license.workspace = true
repository.workspace = true
authors.workspace = true
description = "Native GUI for Terrafier — interactive world painting with GPU rendering" description = "Native GUI for Terrafier — interactive world painting with GPU rendering"
[package.metadata.wix]
upgrade-guid = "A7365100-6923-4DAC-B4A5-2BD817EB802B"
path-guid = "097E8AD4-180B-4B52-8BFE-04AA942D03A6"
license = false
eula = false
[dependencies] [dependencies]
terrafier-core = { path = "../core" } terrafier-core = { path = "../core" }
anyhow.workspace = true anyhow.workspace = true

View file

@ -74,6 +74,8 @@ pub struct TerrafierApp {
pub renderer: Option<GpuRenderer>, pub renderer: Option<GpuRenderer>,
pub show_gpu_render: bool, pub show_gpu_render: bool,
pub gpu_status: GpuStatus, pub gpu_status: GpuStatus,
pub view_offset: egui::Vec2,
pub view_zoom: f32,
pub cpu_texture_handle: Option<egui::TextureHandle>, pub cpu_texture_handle: Option<egui::TextureHandle>,
pub layer_visible: [bool; 6], pub layer_visible: [bool; 6],
} }
@ -96,7 +98,10 @@ impl TerrafierApp {
gpu_renderer.register_texture(&mut renderer_lock); gpu_renderer.register_texture(&mut renderer_lock);
(Some(gpu_renderer), GpuStatus::Enabled) (Some(gpu_renderer), GpuStatus::Enabled)
} else { } else {
(None, GpuStatus::FallbackCpu("GPU init failed — no wgpu adapter found")) (
None,
GpuStatus::FallbackCpu("GPU init failed — no wgpu adapter found"),
)
}; };
Self { Self {
@ -124,9 +129,10 @@ impl TerrafierApp {
renderer, renderer,
show_gpu_render: true, show_gpu_render: true,
gpu_status, gpu_status,
view_offset: egui::Vec2::ZERO,
view_zoom: 1.0,
cpu_texture_handle: None, cpu_texture_handle: None,
layer_visible: [false; 6], layer_visible: [false; 6],
} }
} }
} }

View file

@ -14,7 +14,7 @@ impl TerrafierApp {
pub fn undo(&mut self) { pub fn undo(&mut self) {
if let Some(op) = self.undo_stack.pop_back() { if let Some(op) = self.undo_stack.pop_back() {
if let Some(ref mut world) = self.world { if let Some(ref mut world) = self.world {
let dim = world.overworld_mut().expect("world has no overworld"); if let Some(dim) = world.overworld_mut() {
let inv = op.inverse(); let inv = op.inverse();
if let Err(e) = inv.apply(dim) { if let Err(e) = inv.apply(dim) {
self.status_message = format!("Undo error: {:?}", e); self.status_message = format!("Undo error: {:?}", e);
@ -23,7 +23,11 @@ impl TerrafierApp {
} }
self.redo_stack.push(op); self.redo_stack.push(op);
self.status_message = "Undo".to_string(); self.status_message = "Undo".to_string();
return;
} }
}
self.undo_stack.push_back(op);
self.status_message = "Undo error: world has no overworld".to_string();
} else { } else {
self.status_message = "Nothing to undo".to_string(); self.status_message = "Nothing to undo".to_string();
} }
@ -32,7 +36,7 @@ impl TerrafierApp {
pub fn redo(&mut self) { pub fn redo(&mut self) {
if let Some(op) = self.redo_stack.pop() { if let Some(op) = self.redo_stack.pop() {
if let Some(ref mut world) = self.world { if let Some(ref mut world) = self.world {
let dim = world.overworld_mut().expect("world has no overworld"); if let Some(dim) = world.overworld_mut() {
if let Err(e) = op.apply(dim) { if let Err(e) = op.apply(dim) {
self.status_message = format!("Redo error: {:?}", e); self.status_message = format!("Redo error: {:?}", e);
self.redo_stack.push(op); self.redo_stack.push(op);
@ -40,7 +44,11 @@ impl TerrafierApp {
} }
self.undo_stack.push_back(op); self.undo_stack.push_back(op);
self.status_message = "Redo".to_string(); self.status_message = "Redo".to_string();
return;
} }
}
self.redo_stack.push(op);
self.status_message = "Redo error: world has no overworld".to_string();
} else { } else {
self.status_message = "Nothing to redo".to_string(); self.status_message = "Nothing to redo".to_string();
} }

View file

@ -71,8 +71,8 @@ impl eframe::App for TerrafierApp {
&& let Some(tile) = world.dimensions[0].tiles.values().next() && let Some(tile) = world.dimensions[0].tiles.values().next()
{ {
renderer.render( renderer.render(
&tile.heightmap.as_slice().try_into().expect("heightmap size"), &tile.heightmap.as_slice().try_into().unwrap_or_else(|_| [0; 16384]),
&tile.terrain.as_slice().try_into().expect("terrain size"), &tile.terrain.as_slice().try_into().unwrap_or_else(|_| [0; 16384]),
self.show_heightmap, self.show_heightmap,
); );

View file

@ -47,29 +47,61 @@ impl GpuRenderer {
needed_storage, needed_storage,
); );
} }
let bind_group_layout = let bind_group_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("terrain_bind_group_layout"), label: Some("terrain_bind_group_layout"),
entries: &[ entries: &[
wgpu::BindGroupLayoutEntry { binding: 0, visibility: wgpu::ShaderStages::COMPUTE, ty: wgpu::BindingType::Buffer { ty: wgpu::BufferBindingType::Uniform, has_dynamic_offset: false, min_binding_size: None }, count: None }, wgpu::BindGroupLayoutEntry {
wgpu::BindGroupLayoutEntry { binding: 1, visibility: wgpu::ShaderStages::COMPUTE, ty: wgpu::BindingType::Buffer { ty: wgpu::BufferBindingType::Storage { read_only: true }, has_dynamic_offset: false, min_binding_size: None }, count: None }, binding: 0,
wgpu::BindGroupLayoutEntry { binding: 2, visibility: wgpu::ShaderStages::COMPUTE, ty: wgpu::BindingType::Buffer { ty: wgpu::BufferBindingType::Storage { read_only: true }, has_dynamic_offset: false, min_binding_size: None }, count: None }, visibility: wgpu::ShaderStages::COMPUTE,
wgpu::BindGroupLayoutEntry { binding: 3, visibility: wgpu::ShaderStages::COMPUTE, ty: wgpu::BindingType::Buffer { ty: wgpu::BufferBindingType::Storage { read_only: false }, has_dynamic_offset: false, min_binding_size: None }, count: None }, ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Uniform,
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
},
wgpu::BindGroupLayoutEntry {
binding: 1,
visibility: wgpu::ShaderStages::COMPUTE,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Storage { read_only: true },
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
},
wgpu::BindGroupLayoutEntry {
binding: 2,
visibility: wgpu::ShaderStages::COMPUTE,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Storage { read_only: true },
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
},
wgpu::BindGroupLayoutEntry {
binding: 3,
visibility: wgpu::ShaderStages::COMPUTE,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Storage { read_only: false },
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
},
], ],
}); });
let shader_module = let shader_module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("terrain_shader"), label: Some("terrain_shader"),
source: wgpu::ShaderSource::Wgsl(shader_source.into()), source: wgpu::ShaderSource::Wgsl(shader_source.into()),
}); });
let pipeline_layout = let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("terrain_pipeline_layout"), label: Some("terrain_pipeline_layout"),
bind_group_layouts: &[&bind_group_layout], bind_group_layouts: &[&bind_group_layout],
push_constant_ranges: &[], push_constant_ranges: &[],
}); });
let compute_pipeline = let compute_pipeline = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
label: Some("terrain_compute_pipeline"), label: Some("terrain_compute_pipeline"),
layout: Some(&pipeline_layout), layout: Some(&pipeline_layout),
module: &shader_module, module: &shader_module,
@ -78,26 +110,40 @@ impl GpuRenderer {
cache: None, cache: None,
}); });
let heightmap_buffer = device.create_buffer(&wgpu::BufferDescriptor { let heightmap_buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("heightmap_buffer"), size: 8192 * 4, label: Some("heightmap_buffer"),
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST, mapped_at_creation: false, size: 8192 * 4,
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
}); });
let terrain_buffer = device.create_buffer(&wgpu::BufferDescriptor { let terrain_buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("terrain_buffer"), size: 4096 * 4, label: Some("terrain_buffer"),
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST, mapped_at_creation: false, size: 4096 * 4,
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
}); });
let uniform_buffer = device.create_buffer(&wgpu::BufferDescriptor { let uniform_buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("uniform_buffer"), size: std::mem::size_of::<Uniforms>() as u64, label: Some("uniform_buffer"),
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, mapped_at_creation: false, size: std::mem::size_of::<Uniforms>() as u64,
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
}); });
let output_size = (width * height * 4) as u64; let output_size = (width * height * 4) as u64;
let output_buffer = device.create_buffer(&wgpu::BufferDescriptor { let output_buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("output_buffer"), size: output_size, label: Some("output_buffer"),
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_SRC, mapped_at_creation: false, size: output_size,
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_SRC,
mapped_at_creation: false,
}); });
let output_texture = device.create_texture(&wgpu::TextureDescriptor { let output_texture = device.create_texture(&wgpu::TextureDescriptor {
label: Some("output_texture"), label: Some("output_texture"),
size: wgpu::Extent3d { width, height, depth_or_array_layers: 1 }, size: wgpu::Extent3d {
mip_level_count: 1, sample_count: 1, dimension: wgpu::TextureDimension::D2, width,
height,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Rgba8UnormSrgb, format: wgpu::TextureFormat::Rgba8UnormSrgb,
view_formats: &[wgpu::TextureFormat::Rgba8UnormSrgb], view_formats: &[wgpu::TextureFormat::Rgba8UnormSrgb],
usage: wgpu::TextureUsages::COPY_DST | wgpu::TextureUsages::TEXTURE_BINDING, usage: wgpu::TextureUsages::COPY_DST | wgpu::TextureUsages::TEXTURE_BINDING,
@ -107,16 +153,54 @@ impl GpuRenderer {
label: Some("terrain_bind_group"), label: Some("terrain_bind_group"),
layout: &bind_group_layout, layout: &bind_group_layout,
entries: &[ entries: &[
wgpu::BindGroupEntry { binding: 0, resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding { buffer: &uniform_buffer, offset: 0, size: None }) }, wgpu::BindGroupEntry {
wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding { buffer: &heightmap_buffer, offset: 0, size: None }) }, binding: 0,
wgpu::BindGroupEntry { binding: 2, resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding { buffer: &terrain_buffer, offset: 0, size: None }) }, resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding {
wgpu::BindGroupEntry { binding: 3, resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding { buffer: &output_buffer, offset: 0, size: None }) }, buffer: &uniform_buffer,
offset: 0,
size: None,
}),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding {
buffer: &heightmap_buffer,
offset: 0,
size: None,
}),
},
wgpu::BindGroupEntry {
binding: 2,
resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding {
buffer: &terrain_buffer,
offset: 0,
size: None,
}),
},
wgpu::BindGroupEntry {
binding: 3,
resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding {
buffer: &output_buffer,
offset: 0,
size: None,
}),
},
], ],
}); });
Self { Self {
device, queue, compute_pipeline, heightmap_buffer, terrain_buffer, device,
uniform_buffer, output_buffer, output_texture, output_texture_view, bind_group, queue,
texture_id: None, width, height, compute_pipeline,
heightmap_buffer,
terrain_buffer,
uniform_buffer,
output_buffer,
output_texture,
output_texture_view,
bind_group,
texture_id: None,
width,
height,
last_heightmap: vec![0i16; 16384], last_heightmap: vec![0i16; 16384],
last_terrain: vec![0u8; 16384], last_terrain: vec![0u8; 16384],
last_show_heightmap: false, last_show_heightmap: false,
@ -125,16 +209,25 @@ impl GpuRenderer {
} }
pub(crate) fn pack_heightmap(heightmap: &[i16; 16384]) -> Vec<u32> { pub(crate) fn pack_heightmap(heightmap: &[i16; 16384]) -> Vec<u32> {
heightmap.chunks(2).map(|chunk| { heightmap
.chunks(2)
.map(|chunk| {
let low = chunk[0] as u32 & 0xFFFF; let low = chunk[0] as u32 & 0xFFFF;
let high = chunk[1] as u32 & 0xFFFF; let high = chunk[1] as u32 & 0xFFFF;
low | (high << 16) low | (high << 16)
}).collect() })
.collect()
} }
pub(crate) fn pack_terrain(terrain: &[u8; 16384]) -> Vec<u32> { pub(crate) fn pack_terrain(terrain: &[u8; 16384]) -> Vec<u32> {
terrain.chunks(4).map(|chunk| { terrain
chunk[0] as u32 | (chunk[1] as u32) << 8 | (chunk[2] as u32) << 16 | (chunk[3] as u32) << 24 .chunks(4)
}).collect() .map(|chunk| {
chunk[0] as u32
| (chunk[1] as u32) << 8
| (chunk[2] as u32) << 16
| (chunk[3] as u32) << 24
})
.collect()
} }
} }

View file

@ -2,21 +2,15 @@ use crate::renderer::GpuRenderer;
use crate::renderer::Uniforms; use crate::renderer::Uniforms;
fn as_u8_slice<T>(data: &[T]) -> &[u8] { fn as_u8_slice<T>(data: &[T]) -> &[u8] {
unsafe { // SAFETY: Reinterprets `&[T]` as `&[u8]` for GPU buffer upload.
std::slice::from_raw_parts( // T is a primitive type with no padding, and the byte layout is stable.
data.as_ptr() as *const u8, unsafe { std::slice::from_raw_parts(data.as_ptr() as *const u8, std::mem::size_of_val(data)) }
std::mem::size_of_val(data),
)
}
} }
fn as_u8_value<T>(data: &T) -> &[u8] { fn as_u8_value<T>(data: &T) -> &[u8] {
unsafe { // SAFETY: Reinterprets a single `&T` as `&[u8]` for GPU buffer upload.
std::slice::from_raw_parts( // T is a primitive type with no padding, and the byte layout is stable.
data as *const T as *const u8, unsafe { std::slice::from_raw_parts(data as *const T as *const u8, std::mem::size_of::<T>()) }
std::mem::size_of::<T>(),
)
}
} }
impl GpuRenderer { impl GpuRenderer {
@ -39,8 +33,8 @@ impl GpuRenderer {
self.last_show_heightmap = show_heightmap; self.last_show_heightmap = show_heightmap;
self.needs_upload = false; self.needs_upload = false;
let min_height = *heightmap.iter().min().unwrap(); let min_height = *heightmap.iter().min().unwrap_or(&0);
let max_height = *heightmap.iter().max().unwrap(); let max_height = *heightmap.iter().max().unwrap_or(&255);
let packed_heightmap = Self::pack_heightmap(heightmap); let packed_heightmap = Self::pack_heightmap(heightmap);
self.queue self.queue

View file

@ -1,21 +1,45 @@
use crate::app::{
LAYER_BIOME_IDX, LAYER_CAVES_IDX, LAYER_FROST_IDX, LAYER_RESOURCES_IDX, LAYER_RIVER_IDX,
LAYER_TREES_IDX,
};
use egui::{Color32, ColorImage, TextureOptions, Vec2}; use egui::{Color32, ColorImage, TextureOptions, Vec2};
use terrafier_core::model::tile::{TILE_SIZE, LayerBuffer}; use terrafier_core::model::layers::{
use terrafier_core::model::layers::{LAYER_CAVES, LAYER_RIVER, LAYER_FROST, LAYER_TREES, LAYER_BIOME, LAYER_RESOURCES}; LAYER_BIOME, LAYER_CAVES, LAYER_FROST, LAYER_RESOURCES, LAYER_RIVER, LAYER_TREES,
use crate::app::{LAYER_CAVES_IDX, LAYER_RIVER_IDX, LAYER_FROST_IDX, LAYER_TREES_IDX, LAYER_BIOME_IDX, LAYER_RESOURCES_IDX}; };
use terrafier_core::model::tile::{LayerBuffer, TILE_SIZE};
const LAYER_IDS: [u32; 6] = [LAYER_CAVES, LAYER_RIVER, LAYER_FROST, LAYER_TREES, LAYER_BIOME, LAYER_RESOURCES]; const LAYER_IDS: [u32; 6] = [
LAYER_CAVES,
LAYER_RIVER,
LAYER_FROST,
LAYER_TREES,
LAYER_BIOME,
LAYER_RESOURCES,
];
fn layer_value(tile: &terrafier_core::model::tile::Tile, idx: usize, layer_idx: usize) -> Option<f32> { fn layer_value(
tile: &terrafier_core::model::tile::Tile,
idx: usize,
layer_idx: usize,
) -> Option<f32> {
let layer_id = LAYER_IDS[layer_idx]; let layer_id = LAYER_IDS[layer_idx];
let buf = tile.layer_data.get(&layer_id)?; let buf = tile.layer_data.get(&layer_id)?;
let val = match buf { let val = match buf {
LayerBuffer::Bit(data) => { LayerBuffer::Bit(data) => {
let word = data.get(idx / 64)?; let word = data.get(idx / 64)?;
Some(if (word >> (idx % 64)) & 1 != 0 { 1.0 } else { 0.0 }) Some(if (word >> (idx % 64)) & 1 != 0 {
1.0
} else {
0.0
})
} }
LayerBuffer::Nibble(data) => { LayerBuffer::Nibble(data) => {
let byte = *data.get(idx / 2)?; let byte = *data.get(idx / 2)?;
let nib = if idx % 2 == 0 { byte & 0x0F } else { (byte >> 4) & 0x0F }; let nib = if idx % 2 == 0 {
byte & 0x0F
} else {
(byte >> 4) & 0x0F
};
Some(nib as f32 / 15.0) Some(nib as f32 / 15.0)
} }
LayerBuffer::Byte(data) => { LayerBuffer::Byte(data) => {
@ -65,7 +89,9 @@ const TERRAIN_COLORS: [Color32; 7] = [
]; ];
pub fn render(ui: &mut egui::Ui, app: &mut crate::app::TerrafierApp) { pub fn render(ui: &mut egui::Ui, app: &mut crate::app::TerrafierApp) {
let Some(ref world) = app.world else { return; }; let Some(ref world) = app.world else {
return;
};
let dim = &world.dimensions[0]; let dim = &world.dimensions[0];
let mut min_tx = i32::MAX; let mut min_tx = i32::MAX;
@ -79,20 +105,22 @@ pub fn render(ui: &mut egui::Ui, app: &mut crate::app::TerrafierApp) {
max_tz = max_tz.max(tz); max_tz = max_tz.max(tz);
} }
let display_size = 64u32; let base_size = 64u32;
let grid_w = ((max_tx - min_tx + 1) * display_size as i32) as usize; let zoom = app.view_zoom;
let grid_h = ((max_tz - min_tz + 1) * display_size as i32) as usize;
let grid_w = ((max_tx - min_tx + 1) * base_size as i32) as usize;
let grid_h = ((max_tz - min_tz + 1) * base_size as i32) as usize;
let mut img_data = vec![0u8; grid_w * grid_h * 4]; let mut img_data = vec![0u8; grid_w * grid_h * 4];
for (&(tx, tz), tile) in &dim.tiles { for (&(tx, tz), tile) in &dim.tiles {
let px = ((tx - min_tx) * display_size as i32) as usize; let px = ((tx - min_tx) * base_size as i32) as usize;
let pz = ((tz - min_tz) * display_size as i32) as usize; let pz = ((tz - min_tz) * base_size as i32) as usize;
for ly in 0..display_size as usize { for ly in 0..base_size as usize {
for lx in 0..display_size as usize { for lx in 0..base_size as usize {
let sx = (lx * TILE_SIZE / display_size as usize).min(TILE_SIZE - 1); let sx = (lx * TILE_SIZE / base_size as usize).min(TILE_SIZE - 1);
let sz = (ly * TILE_SIZE / display_size as usize).min(TILE_SIZE - 1); let sz = (ly * TILE_SIZE / base_size as usize).min(TILE_SIZE - 1);
let idx = sz * TILE_SIZE + sx; let idx = sz * TILE_SIZE + sx;
let terrain_type = tile.terrain[idx] as usize; let terrain_type = tile.terrain[idx] as usize;
@ -127,7 +155,11 @@ pub fn render(ui: &mut egui::Ui, app: &mut crate::app::TerrafierApp) {
if app.layer_visible[li] { if app.layer_visible[li] {
if let Some(val) = layer_value(tile, idx, li) { if let Some(val) = layer_value(tile, idx, li) {
if let Some(over) = layer_overlay(li, val) { if let Some(over) = layer_overlay(li, val) {
let alpha = if val > 0.0 { (val * 0.6 + 0.2).min(0.8) } else { 0.0 }; let alpha = if val > 0.0 {
(val * 0.6 + 0.2).min(0.8)
} else {
0.0
};
(r, g, b) = blend_overlay((r, g, b), over, alpha); (r, g, b) = blend_overlay((r, g, b), over, alpha);
} }
} }
@ -156,13 +188,35 @@ pub fn render(ui: &mut egui::Ui, app: &mut crate::app::TerrafierApp) {
id id
}; };
let desired_size = Vec2::new(grid_w as f32, grid_h as f32); // Viewport: allocate a large area for the world with scroll
let (response, painter) = ui.allocate_painter(desired_size, egui::Sense::click()); let viewport_size = Vec2::new(grid_w as f32 * zoom, grid_h as f32 * zoom);
let (response, painter) = ui.allocate_painter(
viewport_size,
egui::Sense::click_and_drag(),
);
// Handle scroll-wheel zoom
let scroll_delta = ui.input(|i| i.raw_scroll_delta);
if scroll_delta.y != 0.0 {
app.view_zoom = (app.view_zoom * (1.0 + scroll_delta.y * 0.002)).clamp(0.1, 10.0);
}
// Handle drag-to-pan
if response.dragged() {
app.view_offset += response.drag_delta();
}
// Recalculate scaled size after potential zoom change
let scaled_size = base_size as f32 * app.view_zoom;
let offset = app.view_offset;
let rect = response.rect; let rect = response.rect;
painter.image( painter.image(
texture_id, texture_id,
rect, egui::Rect::from_min_max(
egui::pos2(rect.min.x + offset.x, rect.min.y + offset.y),
egui::pos2(rect.min.x + offset.x + grid_w as f32 * app.view_zoom, rect.min.y + offset.y + grid_h as f32 * app.view_zoom),
),
egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)), egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)),
Color32::WHITE, Color32::WHITE,
); );
@ -173,10 +227,10 @@ pub fn render(ui: &mut egui::Ui, app: &mut crate::app::TerrafierApp) {
&& sel_tz >= min_tz && sel_tz >= min_tz
&& sel_tz <= max_tz && sel_tz <= max_tz
{ {
let bx = rect.min.x + (sel_tx - min_tx) as f32 * display_size as f32; let bx = rect.min.x + offset.x + (sel_tx - min_tx) as f32 * scaled_size;
let bz = rect.min.y + (sel_tz - min_tz) as f32 * display_size as f32; let bz = rect.min.y + offset.y + (sel_tz - min_tz) as f32 * scaled_size;
let border_rect = let border_rect =
egui::Rect::from_min_size(egui::pos2(bx, bz), Vec2::splat(display_size as f32)); egui::Rect::from_min_size(egui::pos2(bx, bz), Vec2::splat(scaled_size));
painter.rect_stroke( painter.rect_stroke(
border_rect, border_rect,
0.0, 0.0,
@ -188,13 +242,13 @@ pub fn render(ui: &mut egui::Ui, app: &mut crate::app::TerrafierApp) {
if let (Some((sel_tx, sel_tz)), Some(bx), Some(bz)) = if let (Some((sel_tx, sel_tz)), Some(bx), Some(bz)) =
(app.selected_tile, app.brush_local_x, app.brush_local_z) (app.selected_tile, app.brush_local_x, app.brush_local_z)
{ {
let tile_x_in_pixels = (sel_tx - min_tx) as f32 * display_size as f32; let tile_x_in_pixels = (sel_tx - min_tx) as f32 * scaled_size;
let tile_z_in_pixels = (sel_tz - min_tz) as f32 * display_size as f32; let tile_z_in_pixels = (sel_tz - min_tz) as f32 * scaled_size;
let brush_x = let brush_x =
rect.min.x + tile_x_in_pixels + (bx as f32 * display_size as f32 / TILE_SIZE as f32); rect.min.x + offset.x + tile_x_in_pixels + (bx as f32 * scaled_size / TILE_SIZE as f32);
let brush_z = let brush_z =
rect.min.y + tile_z_in_pixels + (bz as f32 * display_size as f32 / TILE_SIZE as f32); rect.min.y + offset.y + tile_z_in_pixels + (bz as f32 * scaled_size / TILE_SIZE as f32);
let brush_radius_px = app.brush_radius as f32 * display_size as f32 / TILE_SIZE as f32; let brush_radius_px = app.brush_radius as f32 * scaled_size / TILE_SIZE as f32;
painter.circle_stroke( painter.circle_stroke(
egui::pos2(brush_x, brush_z), egui::pos2(brush_x, brush_z),
brush_radius_px.max(2.0), brush_radius_px.max(2.0),
@ -202,20 +256,21 @@ pub fn render(ui: &mut egui::Ui, app: &mut crate::app::TerrafierApp) {
); );
} }
// Click-to-select with inverse transform
if response.clicked() if response.clicked()
&& let Some(pos) = response.interact_pointer_pos() && let Some(pos) = response.interact_pointer_pos()
{ {
let lx = pos.x - rect.min.x; let lx = pos.x - rect.min.x - offset.x;
let lz = pos.y - rect.min.y; let lz = pos.y - rect.min.y - offset.y;
if lx >= 0.0 && lz >= 0.0 { if lx >= 0.0 && lz >= 0.0 {
let tx = (lx / display_size as f32).floor() as i32 + min_tx; let tx = (lx / scaled_size).floor() as i32 + min_tx;
let tz = (lz / display_size as f32).floor() as i32 + min_tz; let tz = (lz / scaled_size).floor() as i32 + min_tz;
if dim.tiles.contains_key(&(tx, tz)) { if dim.tiles.contains_key(&(tx, tz)) {
app.selected_tile = Some((tx, tz)); app.selected_tile = Some((tx, tz));
let local_x = let local_x =
((lx as u32 % display_size) * TILE_SIZE as u32 / display_size).min(127); ((lx as u32 % scaled_size as u32) * TILE_SIZE as u32 / scaled_size as u32).min(127);
let local_z = let local_z =
((lz as u32 % display_size) * TILE_SIZE as u32 / display_size).min(127); ((lz as u32 % scaled_size as u32) * TILE_SIZE as u32 / scaled_size as u32).min(127);
app.brush_local_x = Some(local_x); app.brush_local_x = Some(local_x);
app.brush_local_z = Some(local_z); app.brush_local_z = Some(local_z);
app.status_message = format!( app.status_message = format!(

View file

@ -11,18 +11,30 @@ pub fn try_render(ui: &mut egui::Ui, app: &mut crate::app::TerrafierApp) -> bool
&& let Some(texture_id) = renderer.texture_id && let Some(texture_id) = renderer.texture_id
{ {
let desired_size = Vec2::new(512.0, 512.0); let desired_size = Vec2::new(512.0, 512.0);
let (response, painter) = ui.allocate_painter(desired_size, egui::Sense::click()); let (response, painter) = ui.allocate_painter(
let rect = response.rect; desired_size,
painter.image( egui::Sense::click_and_drag(),
texture_id,
rect,
egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)),
Color32::WHITE,
); );
let rect = response.rect;
let display_size = rect.width() / 4.0; // Handle scroll-wheel zoom
let scroll_delta = ui.input(|i| i.raw_scroll_delta);
if scroll_delta.y != 0.0 {
app.view_zoom = (app.view_zoom * (1.0 + scroll_delta.y * 0.002)).clamp(0.1, 10.0);
}
if let Some((sel_tx, sel_tz)) = app.selected_tile { // Handle drag-to-pan
if response.dragged() {
app.view_offset += response.drag_delta();
}
let zoom = app.view_zoom;
let offset = app.view_offset;
let base_display_size = rect.width() / 4.0;
let display_size = base_display_size * zoom;
// Find tile bounds for positioning
let mut min_tx = i32::MAX; let mut min_tx = i32::MAX;
let mut max_tx = i32::MIN; let mut max_tx = i32::MIN;
let mut min_tz = i32::MAX; let mut min_tz = i32::MAX;
@ -34,14 +46,29 @@ pub fn try_render(ui: &mut egui::Ui, app: &mut crate::app::TerrafierApp) -> bool
max_tz = max_tz.max(tz); max_tz = max_tz.max(tz);
} }
let bx = rect.min.x; // Draw the GPU-rendered tile texture at the correct position
let bz = rect.min.y; // GPU only renders the first tile - position it correctly
let sel_bx = bx + (sel_tx - min_tx) as f32 * display_size; // Use a default position for the rendered tile
let sel_bz = bz + (sel_tz - min_tz) as f32 * display_size; let first_tile_pos = dim.tiles.keys().next().copied().unwrap_or((0, 0));
let border_rect = egui::Rect::from_min_size( let tex_origin_x = rect.min.x + offset.x + (first_tile_pos.0 - min_tx) as f32 * display_size;
egui::pos2(sel_bx, sel_bz), let tex_origin_y = rect.min.y + offset.y + (first_tile_pos.1 - min_tz) as f32 * display_size;
let tex_rect = egui::Rect::from_min_size(
egui::pos2(tex_origin_x, tex_origin_y),
Vec2::splat(display_size), Vec2::splat(display_size),
); );
painter.image(
texture_id,
tex_rect,
egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)),
Color32::WHITE,
);
if let Some((sel_tx, sel_tz)) = app.selected_tile {
let bx = rect.min.x + offset.x + (sel_tx - min_tx) as f32 * display_size;
let bz = rect.min.y + offset.y + (sel_tz - min_tz) as f32 * display_size;
let border_rect =
egui::Rect::from_min_size(egui::pos2(bx, bz), Vec2::splat(display_size));
painter.rect_stroke( painter.rect_stroke(
border_rect, border_rect,
0.0, 0.0,
@ -53,18 +80,12 @@ pub fn try_render(ui: &mut egui::Ui, app: &mut crate::app::TerrafierApp) -> bool
if let (Some((sel_tx, sel_tz)), Some(bx), Some(bz)) = if let (Some((sel_tx, sel_tz)), Some(bx), Some(bz)) =
(app.selected_tile, app.brush_local_x, app.brush_local_z) (app.selected_tile, app.brush_local_x, app.brush_local_z)
{ {
let mut min_tx = i32::MAX;
let mut min_tz = i32::MAX;
for &(tx, tz) in dim.tiles.keys() {
min_tx = min_tx.min(tx);
min_tz = min_tz.min(tz);
}
let tile_x_in_pixels = (sel_tx - min_tx) as f32 * display_size; let tile_x_in_pixels = (sel_tx - min_tx) as f32 * display_size;
let tile_z_in_pixels = (sel_tz - min_tz) as f32 * display_size; let tile_z_in_pixels = (sel_tz - min_tz) as f32 * display_size;
let brush_x = rect.min.x + tile_x_in_pixels let brush_x =
+ (bx as f32 * display_size / TILE_SIZE as f32); rect.min.x + offset.x + tile_x_in_pixels + (bx as f32 * display_size / TILE_SIZE as f32);
let brush_z = rect.min.y + tile_z_in_pixels let brush_z =
+ (bz as f32 * display_size / TILE_SIZE as f32); rect.min.y + offset.y + tile_z_in_pixels + (bz as f32 * display_size / TILE_SIZE as f32);
let brush_radius_px = app.brush_radius as f32 * display_size / TILE_SIZE as f32; let brush_radius_px = app.brush_radius as f32 * display_size / TILE_SIZE as f32;
painter.circle_stroke( painter.circle_stroke(
egui::pos2(brush_x, brush_z), egui::pos2(brush_x, brush_z),
@ -76,23 +97,19 @@ pub fn try_render(ui: &mut egui::Ui, app: &mut crate::app::TerrafierApp) -> bool
if response.clicked() if response.clicked()
&& let Some(pos) = response.interact_pointer_pos() && let Some(pos) = response.interact_pointer_pos()
{ {
let lx = pos.x - rect.min.x; let lx = pos.x - rect.min.x - offset.x;
let lz = pos.y - rect.min.y; let lz = pos.y - rect.min.y - offset.y;
if lx >= 0.0 && lz >= 0.0 { if lx >= 0.0 && lz >= 0.0 {
let mut min_tx = i32::MAX;
let mut min_tz = i32::MAX;
for &(tx, tz) in dim.tiles.keys() {
min_tx = min_tx.min(tx);
min_tz = min_tz.min(tz);
}
let tx = (lx / display_size).floor() as i32 + min_tx; let tx = (lx / display_size).floor() as i32 + min_tx;
let tz = (lz / display_size).floor() as i32 + min_tz; let tz = (lz / display_size).floor() as i32 + min_tz;
if dim.tiles.contains_key(&(tx, tz)) { if dim.tiles.contains_key(&(tx, tz)) {
app.selected_tile = Some((tx, tz)); app.selected_tile = Some((tx, tz));
let local_x = let local_x = ((lx as u32 % display_size as u32) * TILE_SIZE as u32
((lx as u32 % display_size as u32) * TILE_SIZE as u32 / display_size as u32).min(127); / display_size as u32)
let local_z = .min(127);
((lz as u32 % display_size as u32) * TILE_SIZE as u32 / display_size as u32).min(127); let local_z = ((lz as u32 % display_size as u32) * TILE_SIZE as u32
/ display_size as u32)
.min(127);
app.brush_local_x = Some(local_x); app.brush_local_x = Some(local_x);
app.brush_local_z = Some(local_z); app.brush_local_z = Some(local_z);
app.status_message = format!( app.status_message = format!(

View file

@ -1,14 +1,14 @@
use std::sync::Arc; use std::sync::Arc;
use terrafier_core::Terrain; use terrafier_core::Terrain;
use terrafier_core::model::types::SymmetricBrush;
use terrafier_core::model::tile::TILE_SIZE; use terrafier_core::model::tile::TILE_SIZE;
use terrafier_core::model::types::SymmetricBrush;
use terrafier_core::ops::operations::{ use terrafier_core::ops::operations::{
ErodeOperation, FillOperation, FlattenOperation, HeightOperation, MultiTileOperation, ErodeOperation, FillOperation, FlattenOperation, HeightOperation, MultiTileOperation,
Operation, PaintOperation, SmoothOperation, Operation, PaintOperation, SmoothOperation,
}; };
use crate::app::{TerrafierApp, ToolMode, LAYER_NAMES}; use crate::app::{LAYER_NAMES, TerrafierApp, ToolMode};
pub fn show_tools_panel(ui: &mut egui::Ui, app: &mut TerrafierApp) { pub fn show_tools_panel(ui: &mut egui::Ui, app: &mut TerrafierApp) {
ui.heading("Tools"); ui.heading("Tools");
@ -83,7 +83,10 @@ fn apply_tool(app: &mut TerrafierApp) {
let Some(ref mut world) = app.world else { let Some(ref mut world) = app.world else {
return; return;
}; };
let dim = world.overworld_mut().expect("no overworld"); let Some(dim) = world.overworld_mut() else {
app.status_message = "World has no overworld".to_string();
return;
};
let center = app.brush_local_x.unwrap_or(64); let center = app.brush_local_x.unwrap_or(64);
let center_z = app.brush_local_z.unwrap_or(64); let center_z = app.brush_local_z.unwrap_or(64);

228
gui/wix/main.wxs Normal file
View file

@ -0,0 +1,228 @@
<?xml version='1.0' encoding='windows-1252'?>
<!--
Copyright (C) 2017 Christopher R. Field.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<!--
The "cargo wix" subcommand provides a variety of predefined variables available
for customization of this template. The values for each variable are set at
installer creation time. The following variables are available:
TargetTriple = The rustc target triple name.
TargetEnv = The rustc target environment. This is typically either
"msvc" or "gnu" depending on the toolchain downloaded and
installed.
TargetVendor = The rustc target vendor. This is typically "pc", but Rust
does support other vendors, like "uwp".
CargoTargetBinDir = The complete path to the directory containing the
binaries (exes) to include. The default would be
"target\release\". If an explicit rustc target triple is
used, i.e. cross-compiling, then the default path would
be "target\<CARGO_TARGET>\<CARGO_PROFILE>",
where "<CARGO_TARGET>" is replaced with the "CargoTarget"
variable value and "<CARGO_PROFILE>" is replaced with the
value from the "CargoProfile" variable. This can also
be overridden manually with the "target-bin-dir" flag.
CargoTargetDir = The path to the directory for the build artifacts, i.e.
"target".
CargoProfile = The cargo profile used to build the binaries
(usually "debug" or "release").
Version = The version for the installer. The default is the
"Major.Minor.Fix" semantic versioning number of the Rust
package.
-->
<!--
Please do not remove these pre-processor If-Else blocks. These are used with
the `cargo wix` subcommand to automatically determine the installation
destination for 32-bit versus 64-bit installers. Removal of these lines will
cause installation errors.
-->
<?if $(sys.BUILDARCH) = x64 or $(sys.BUILDARCH) = arm64 ?>
<?define PlatformProgramFilesFolder = "ProgramFiles64Folder" ?>
<?else ?>
<?define PlatformProgramFilesFolder = "ProgramFilesFolder" ?>
<?endif ?>
<Wix xmlns='http://schemas.microsoft.com/wix/2006/wi'>
<Product
Id='*'
Name='terrafier-gui'
UpgradeCode='A7365100-6923-4DAC-B4A5-2BD817EB802B'
Manufacturer='Loki'
Language='1033'
Codepage='1252'
Version='$(var.Version)'>
<Package Id='*'
Keywords='Installer'
Description='Native GUI for Terrafier — interactive world painting with GPU rendering'
Manufacturer='Loki'
InstallerVersion='450'
Languages='1033'
Compressed='yes'
InstallScope='perMachine'
SummaryCodepage='1252'
/>
<MajorUpgrade
Schedule='afterInstallInitialize'
DowngradeErrorMessage='A newer version of [ProductName] is already installed. Setup will now exit.'/>
<Media Id='1' Cabinet='media1.cab' EmbedCab='yes' DiskPrompt='CD-ROM #1'/>
<Property Id='DiskPrompt' Value='terrafier-gui Installation'/>
<Directory Id='TARGETDIR' Name='SourceDir'>
<Directory Id='$(var.PlatformProgramFilesFolder)' Name='PFiles'>
<Directory Id='APPLICATIONFOLDER' Name='terrafier-gui'>
<!--
Enabling the license sidecar file in the installer is a four step process:
1. Uncomment the `Component` tag and its contents.
2. Change the value for the `Source` attribute in the `File` tag to a path
to the file that should be included as the license sidecar file. The path
can, and probably should be, relative to this file.
3. Change the value for the `Name` attribute in the `File` tag to the
desired name for the file when it is installed alongside the `bin` folder
in the installation directory. This can be omitted if the desired name is
the same as the file name.
4. Uncomment the `ComponentRef` tag with the Id attribute value of "License"
further down in this file.
-->
<!--
<Component Id='License' Guid='*'>
<File Id='LicenseFile' Name='ChangeMe' DiskId='1' Source='C:\Path\To\File' KeyPath='yes'/>
</Component>
-->
<Directory Id='Bin' Name='bin'>
<Component Id='Path' Guid='097E8AD4-180B-4B52-8BFE-04AA942D03A6' KeyPath='yes'>
<Environment
Id='PATH'
Name='PATH'
Value='[Bin]'
Permanent='no'
Part='last'
Action='set'
System='yes'/>
</Component>
<Component Id='binary0' Guid='*'>
<File
Id='exe0'
Name='terrafier-gui.exe'
DiskId='1'
Source='$(var.CargoTargetBinDir)\terrafier-gui.exe'
KeyPath='yes'/>
</Component>
</Directory>
</Directory>
</Directory>
</Directory>
<Feature
Id='Binaries'
Title='Application'
Description='Installs all binaries and the license.'
Level='1'
ConfigurableDirectory='APPLICATIONFOLDER'
AllowAdvertise='no'
Display='expand'
Absent='disallow'>
<!--
Uncomment the following `ComponentRef` tag to add the license
sidecar file to the installer.
-->
<!--<ComponentRef Id='License'/>-->
<ComponentRef Id='binary0'/>
<Feature
Id='Environment'
Title='PATH Environment Variable'
Description='Add the install location of the [ProductName] executable to the PATH system environment variable. This allows the [ProductName] executable to be called from any location.'
Level='1'
Absent='allow'>
<ComponentRef Id='Path'/>
</Feature>
</Feature>
<SetProperty Id='ARPINSTALLLOCATION' Value='[APPLICATIONFOLDER]' After='CostFinalize'/>
<!--
Uncomment the following `Icon` and `Property` tags to change the product icon.
The product icon is the graphic that appears in the Add/Remove
Programs control panel for the application.
-->
<!--<Icon Id='ProductICO' SourceFile='wix\Product.ico'/>-->
<!--<Property Id='ARPPRODUCTICON' Value='ProductICO' />-->
<Property Id='ARPHELPLINK' Value='https://github.com/loki5512344/Terrafier'/>
<UI>
<UIRef Id='WixUI_FeatureTree'/>
<!--
Enabling the EULA dialog in the installer is a three step process:
1. Comment out or remove the two `Publish` tags that follow the
`WixVariable` tag.
2. Uncomment the `<WixVariable Id='WixUILicenseRtf' Value='Path\to\Eula.rft'>` tag further down
3. Replace the `Value` attribute of the `WixVariable` tag with
the path to a RTF file that will be used as the EULA and
displayed in the license agreement dialog.
-->
<Publish Dialog='WelcomeDlg' Control='Next' Event='NewDialog' Value='CustomizeDlg' Order='99'>1</Publish>
<Publish Dialog='CustomizeDlg' Control='Back' Event='NewDialog' Value='WelcomeDlg' Order='99'>1</Publish>
</UI>
<!--
Enabling the EULA dialog in the installer requires uncommenting
the following `WixUILicenseRTF` tag and changing the `Value`
attribute.
-->
<!-- <WixVariable Id='WixUILicenseRtf' Value='Relative\Path\to\Eula.rtf'/> -->
<!--
Uncomment the next `WixVariable` tag to customize the installer's
Graphical User Interface (GUI) and add a custom banner image across
the top of each screen. See the WiX Toolset documentation for details
about customization.
The banner BMP dimensions are 493 x 58 pixels.
-->
<!--<WixVariable Id='WixUIBannerBmp' Value='wix\Banner.bmp'/>-->
<!--
Uncomment the next `WixVariable` tag to customize the installer's
Graphical User Interface (GUI) and add a custom image to the first
dialog, or screen. See the WiX Toolset documentation for details about
customization.
The dialog BMP dimensions are 493 x 312 pixels.
-->
<!--<WixVariable Id='WixUIDialogBmp' Value='wix\Dialog.bmp'/>-->
</Product>
</Wix>

7
plugins/example-noisemap/Cargo.lock generated Normal file
View file

@ -0,0 +1,7 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "terrafier-plugin-example-noisemap"
version = "0.1.0"

View file

@ -0,0 +1,12 @@
[package]
name = "terrafier-plugin-example-noisemap"
version = "0.1.0"
edition = "2024"
description = "Example WASM plugin for Terrafier — generates noise-based terrain"
[lib]
crate-type = ["cdylib"]
[profile.release]
opt-level = "s"
lto = true

View file

@ -0,0 +1,4 @@
#!/bin/bash
cd "$(dirname "$0")"
cargo build --target wasm32-unknown-unknown --release
echo "WASM plugin built: $(ls -lh target/wasm32-unknown-unknown/release/*.wasm 2>/dev/null || echo 'BUILD FAILED')"

View file

@ -0,0 +1,216 @@
#![no_std]
// Allocator for WASM — bump allocator
// SAFETY: WASM is single-threaded; no concurrent access to BUMP is possible.
// The allocator is only called during plugin initialization before tile generation.
static mut BUMP: usize = 4096; // start after string data
#[unsafe(no_mangle)]
pub extern "C" fn alloc(size: i32) -> i32 {
// SAFETY: WASM is single-threaded; no concurrent access to BUMP is possible.
// The allocator is only called during plugin initialization before tile generation.
unsafe {
let ptr = BUMP as i32;
BUMP += size as usize;
ptr
}
}
// Host imports
// SAFETY: These host functions are guaranteed by the Terrafier WASM host ABI
// to be callable from within generate_tile. The host validates all coordinates
// (0..127) internally. All set functions are idempotent and panic-free.
unsafe extern "C" {
fn host_log(level: i32, ptr: i32, len: i32);
fn host_get_seed() -> i64;
fn host_tile_size() -> i32;
fn host_get_height(x: i32, z: i32) -> i32;
fn host_set_height(x: i32, z: i32, val: i32);
fn host_get_terrain(x: i32, z: i32) -> i32;
fn host_set_terrain(x: i32, z: i32, val: i32);
fn host_get_water(x: i32, z: i32) -> i32;
fn host_set_water(x: i32, z: i32, val: i32);
// Bulk transfer (faster)
fn host_alloc_buffers() -> i32;
fn host_load_heightmap() -> i32;
fn host_load_terrain() -> i32;
fn host_load_water() -> i32;
fn host_store_heightmap();
fn host_store_terrain();
fn host_store_water();
}
// Simple pseudo-random number generator (LCG)
// SAFETY: WASM execution is single-threaded. RNG_STATE is only accessed
// during tile generation which runs sequentially per tile.
static mut RNG_STATE: u64 = 0;
// SAFETY: WASM execution is single-threaded. RNG_STATE is only accessed
// during tile generation which runs sequentially per tile.
unsafe fn rng() -> u32 {
// SAFETY: Same single-thread guarantee as RNG_STATE declaration.
unsafe {
RNG_STATE = RNG_STATE.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
(RNG_STATE >> 32) as u32
}
}
unsafe fn rng_range(min: f64, max: f64) -> f64 {
// SAFETY: rng() only accesses RNG_STATE, which is safe under single-threaded WASM.
let r = unsafe { rng() } as f64 / u32::MAX as f64;
min + r * (max - min)
}
// Simple 2D noise function (value noise)
unsafe fn noise_2d(x: f64, z: f64, seed: u64) -> f64 {
let xi = x as i32;
let zi = z as i32;
let xf = x - xi as f64;
let zf = z - zi as f64;
fn hash(x: i32, z: i32, seed: u64) -> f64 {
let h = (x as u64).wrapping_mul(374761393)
.wrapping_add((z as u64).wrapping_mul(668265263))
.wrapping_add(seed)
.wrapping_mul(1274126177);
((h ^ (h >> 16)) as u32) as f64 / u32::MAX as f64
}
let n00 = hash(xi, zi, seed);
let n10 = hash(xi + 1, zi, seed);
let n01 = hash(xi, zi + 1, seed);
let n11 = hash(xi + 1, zi + 1, seed);
let sx = xf * xf * (3.0 - 2.0 * xf);
let sz = zf * zf * (3.0 - 2.0 * zf);
let nx0 = n00 + (n10 - n00) * sx;
let nx1 = n01 + (n11 - n01) * sx;
nx0 + (nx1 - nx0) * sz
}
// Helper to log strings from WASM
// SAFETY: Address 0-1024 in WASM linear memory is reserved as a scratch buffer.
// The host guarantees this region is writable. The copy size is bounded by msg length (max 1024).
#[allow(invalid_null_arguments)]
unsafe fn wasm_log(level: i32, msg: &str) {
let bytes = msg.as_bytes();
let ptr = 0usize;
let len = bytes.len().min(1024);
// SAFETY: Address 0 is a valid scratch buffer in WASM linear memory.
// host_log is guaranteed by the host ABI.
unsafe {
core::ptr::copy_nonoverlapping(bytes.as_ptr(), ptr as *mut u8, len);
host_log(level, ptr as i32, len as i32);
}
}
// Plugin metadata
#[unsafe(no_mangle)]
pub extern "C" fn name_ptr() -> i32 { 1024 }
#[unsafe(no_mangle)]
pub extern "C" fn name_len() -> i32 { 16 }
#[unsafe(no_mangle)]
pub extern "C" fn version_ptr() -> i32 { 1040 }
#[unsafe(no_mangle)]
pub extern "C" fn version_len() -> i32 { 5 }
// Plugin type: 3 = TileSource
#[unsafe(no_mangle)]
pub extern "C" fn plugin_type() -> i32 { 3 }
#[unsafe(no_mangle)]
pub extern "C" fn init() -> i32 {
let name = b"Example NoiseMap";
let version = b"1.0.0";
// SAFETY: Addresses 1024+ and 1040+ are reserved for plugin metadata strings.
// The source bytes are valid string literals with static lifetime.
// Destinations are within the first memory page (64KB) guaranteed by the host.
unsafe {
core::ptr::copy_nonoverlapping(name.as_ptr(), 1024 as *mut u8, name.len());
core::ptr::copy_nonoverlapping(version.as_ptr(), 1040 as *mut u8, version.len());
}
0
}
/// Terrain types: 0=Desert, 1=Grass, 2=Forest, 3=Rock, 4=Sand, 5=Swamp, 6=Water
const TERRAIN_GRASS: u8 = 1;
const TERRAIN_FOREST: u8 = 2;
const TERRAIN_ROCK: u8 = 3;
const TERRAIN_WATER: u8 = 6;
#[unsafe(no_mangle)]
pub extern "C" fn generate_tile(tile_x: i32, tile_z: i32, seed_lo: i32, seed_hi: i32) -> i32 {
let seed = (seed_lo as u64) | ((seed_hi as u64) << 32);
// SAFETY: All host_* functions are guaranteed by the Terrafier WASM host ABI
// to be callable from within generate_tile. The host validates all coordinates
// (0..127) internally. host_set_* functions are idempotent and panic-free.
unsafe {
RNG_STATE = seed;
let tile_size = host_tile_size();
let scale = 16.0;
let origin_x = (tile_x as f64) * (tile_size as f64);
let origin_z = (tile_z as f64) * (tile_size as f64);
let water_level = 32;
for tz in 0..tile_size {
for tx in 0..tile_size {
let wx = (tx as f64) + origin_x;
let wz = (tz as f64) + origin_z;
let mut height: f64 = 0.0;
let mut amplitude = 1.0;
let mut frequency = 1.0;
let mut max_val = 0.0;
for _octave in 0..6 {
let nx = wx / scale * frequency;
let nz = wz / scale * frequency;
height += noise_2d(nx, nz, seed.wrapping_add(_octave as u64)) * amplitude;
max_val += amplitude;
amplitude *= 0.5;
frequency *= 2.0;
}
height /= max_val;
height = height * 80.0 + 20.0;
let iheight = height as i32;
host_set_height(tx, tz, iheight);
let terrain = if iheight < water_level + 2 {
TERRAIN_WATER
} else if iheight < water_level + 10 {
TERRAIN_GRASS
} else if iheight > 85 {
TERRAIN_ROCK
} else if rng_range(0.0, 1.0) < 0.3 {
TERRAIN_FOREST
} else {
TERRAIN_GRASS
};
host_set_terrain(tx, tz, terrain as i32);
let water = if iheight < water_level {
8u8
} else {
0u8
};
host_set_water(tx, tz, water as i32);
}
}
}
0
}
#[panic_handler]
fn panic_handler(_info: &core::panic::PanicInfo) -> ! {
loop {}
}

View file

@ -0,0 +1 @@
{"rustc_fingerprint":7812569715578181117,"outputs":{"11652014622397750202":{"success":true,"status":"","code":0,"stdout":"___.wasm\nlib___.rlib\n___.wasm\nlib___.a\n/home/loki/.rustup/toolchains/stable-x86_64-unknown-linux-gnu\noff\n___\ndebug_assertions\npanic=\"abort\"\nproc_macro\ntarget_abi=\"\"\ntarget_arch=\"wasm32\"\ntarget_endian=\"little\"\ntarget_env=\"\"\ntarget_family=\"wasm\"\ntarget_feature=\"bulk-memory\"\ntarget_feature=\"multivalue\"\ntarget_feature=\"mutable-globals\"\ntarget_feature=\"nontrapping-fptoint\"\ntarget_feature=\"reference-types\"\ntarget_feature=\"sign-ext\"\ntarget_has_atomic=\"16\"\ntarget_has_atomic=\"32\"\ntarget_has_atomic=\"64\"\ntarget_has_atomic=\"8\"\ntarget_has_atomic=\"ptr\"\ntarget_os=\"unknown\"\ntarget_pointer_width=\"32\"\ntarget_vendor=\"unknown\"\n","stderr":"warning: dropping unsupported crate type `dylib` for target `wasm32-unknown-unknown`\n\nwarning: dropping unsupported crate type `proc-macro` for target `wasm32-unknown-unknown`\n\nwarning: 2 warnings emitted\n\n"},"7971740275564407648":{"success":true,"status":"","code":0,"stdout":"___\nlib___.rlib\nlib___.so\nlib___.so\nlib___.a\nlib___.so\n/home/loki/.rustup/toolchains/stable-x86_64-unknown-linux-gnu\noff\npacked\nunpacked\n___\ndebug_assertions\npanic=\"unwind\"\nproc_macro\ntarget_abi=\"\"\ntarget_arch=\"x86_64\"\ntarget_endian=\"little\"\ntarget_env=\"gnu\"\ntarget_family=\"unix\"\ntarget_feature=\"fxsr\"\ntarget_feature=\"sse\"\ntarget_feature=\"sse2\"\ntarget_has_atomic=\"16\"\ntarget_has_atomic=\"32\"\ntarget_has_atomic=\"64\"\ntarget_has_atomic=\"8\"\ntarget_has_atomic=\"ptr\"\ntarget_os=\"linux\"\ntarget_pointer_width=\"64\"\ntarget_vendor=\"unknown\"\nunix\n","stderr":""},"17181554143788415700":{"success":true,"status":"","code":0,"stdout":"rustc 1.96.1 (31fca3adb 2026-06-26)\nbinary: rustc\ncommit-hash: 31fca3adb283cc9dfd56b49cdee9a96eb9c96ffd\ncommit-date: 2026-06-26\nhost: x86_64-unknown-linux-gnu\nrelease: 1.96.1\nLLVM version: 22.1.2\n","stderr":""}},"successes":{}}

View file

@ -0,0 +1,3 @@
Signature: 8a477f597d28d172789f06886806bc55
# This file is a cache directory tag created by cargo.
# For information about cache directory tags see https://bford.info/cachedir/

View file

@ -0,0 +1,3 @@
Signature: 8a477f597d28d172789f06886806bc55
# This file is a cache directory tag created by cargo.
# For information about cache directory tags see https://bford.info/cachedir/

View file

@ -0,0 +1 @@
{"rustc":9777074978655410247,"features":"[]","declared_features":"[]","target":2668537824883689644,"profile":17672942494452627365,"path":10763286916239946207,"deps":[],"local":[{"CheckDepInfo":{"dep_info":"wasm32-unknown-unknown/debug/.fingerprint/terrafier-plugin-example-noisemap-14d72cbe84c58ee8/dep-lib-terrafier_plugin_example_noisemap","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":14682669768258224367}

View file

@ -0,0 +1,14 @@
{"$message_type":"diagnostic","message":"function `wasm_log` is never used","code":{"code":"dead_code","explanation":null},"level":"warning","spans":[{"file_name":"src/lib.rs","byte_start":3450,"byte_end":3458,"line_start":96,"line_end":96,"column_start":11,"column_end":19,"is_primary":true,"text":[{"text":"unsafe fn wasm_log(level: i32, msg: &str) {","highlight_start":11,"highlight_end":19}],"label":null,"suggested_replacement":null,"suggestion_applicability":null,"expansion":null}],"children":[{"message":"`#[warn(dead_code)]` (part of `#[warn(unused)]`) on by default","code":null,"level":"note","spans":[],"children":[],"rendered":null}],"rendered":"\u001b[1m\u001b[33mwarning\u001b[0m\u001b[1m: function `wasm_log` is never used\u001b[0m\n \u001b[1m\u001b[94m--> \u001b[0msrc/lib.rs:96:11\n \u001b[1m\u001b[94m|\u001b[0m\n\u001b[1m\u001b[94m96\u001b[0m \u001b[1m\u001b[94m|\u001b[0m unsafe fn wasm_log(level: i32, msg: &str) {\n \u001b[1m\u001b[94m|\u001b[0m \u001b[1m\u001b[33m^^^^^^^^\u001b[0m\n \u001b[1m\u001b[94m|\u001b[0m\n \u001b[1m\u001b[94m= \u001b[0m\u001b[1mnote\u001b[0m: `#[warn(dead_code)]` (part of `#[warn(unused)]`) on by default\n\n"}
{"$message_type":"diagnostic","message":"function `host_log` is never used","code":{"code":"dead_code","explanation":null},"level":"warning","spans":[{"file_name":"src/lib.rs","byte_start":881,"byte_end":889,"line_start":24,"line_end":24,"column_start":8,"column_end":16,"is_primary":true,"text":[{"text":" fn host_log(level: i32, ptr: i32, len: i32);","highlight_start":8,"highlight_end":16}],"label":null,"suggested_replacement":null,"suggestion_applicability":null,"expansion":null}],"children":[],"rendered":"\u001b[1m\u001b[33mwarning\u001b[0m\u001b[1m: function `host_log` is never used\u001b[0m\n \u001b[1m\u001b[94m--> \u001b[0msrc/lib.rs:24:8\n \u001b[1m\u001b[94m|\u001b[0m\n\u001b[1m\u001b[94m24\u001b[0m \u001b[1m\u001b[94m|\u001b[0m fn host_log(level: i32, ptr: i32, len: i32);\n \u001b[1m\u001b[94m|\u001b[0m \u001b[1m\u001b[33m^^^^^^^^\u001b[0m\n\n"}
{"$message_type":"diagnostic","message":"function `host_get_seed` is never used","code":{"code":"dead_code","explanation":null},"level":"warning","spans":[{"file_name":"src/lib.rs","byte_start":930,"byte_end":943,"line_start":25,"line_end":25,"column_start":8,"column_end":21,"is_primary":true,"text":[{"text":" fn host_get_seed() -> i64;","highlight_start":8,"highlight_end":21}],"label":null,"suggested_replacement":null,"suggestion_applicability":null,"expansion":null}],"children":[],"rendered":"\u001b[1m\u001b[33mwarning\u001b[0m\u001b[1m: function `host_get_seed` is never used\u001b[0m\n \u001b[1m\u001b[94m--> \u001b[0msrc/lib.rs:25:8\n \u001b[1m\u001b[94m|\u001b[0m\n\u001b[1m\u001b[94m25\u001b[0m \u001b[1m\u001b[94m|\u001b[0m fn host_get_seed() -> i64;\n \u001b[1m\u001b[94m|\u001b[0m \u001b[1m\u001b[33m^^^^^^^^^^^^^\u001b[0m\n\n"}
{"$message_type":"diagnostic","message":"function `host_get_height` is never used","code":{"code":"dead_code","explanation":null},"level":"warning","spans":[{"file_name":"src/lib.rs","byte_start":993,"byte_end":1008,"line_start":27,"line_end":27,"column_start":8,"column_end":23,"is_primary":true,"text":[{"text":" fn host_get_height(x: i32, z: i32) -> i32;","highlight_start":8,"highlight_end":23}],"label":null,"suggested_replacement":null,"suggestion_applicability":null,"expansion":null}],"children":[],"rendered":"\u001b[1m\u001b[33mwarning\u001b[0m\u001b[1m: function `host_get_height` is never used\u001b[0m\n \u001b[1m\u001b[94m--> \u001b[0msrc/lib.rs:27:8\n \u001b[1m\u001b[94m|\u001b[0m\n\u001b[1m\u001b[94m27\u001b[0m \u001b[1m\u001b[94m|\u001b[0m fn host_get_height(x: i32, z: i32) -> i32;\n \u001b[1m\u001b[94m|\u001b[0m \u001b[1m\u001b[33m^^^^^^^^^^^^^^^\u001b[0m\n\n"}
{"$message_type":"diagnostic","message":"function `host_get_terrain` is never used","code":{"code":"dead_code","explanation":null},"level":"warning","spans":[{"file_name":"src/lib.rs","byte_start":1090,"byte_end":1106,"line_start":29,"line_end":29,"column_start":8,"column_end":24,"is_primary":true,"text":[{"text":" fn host_get_terrain(x: i32, z: i32) -> i32;","highlight_start":8,"highlight_end":24}],"label":null,"suggested_replacement":null,"suggestion_applicability":null,"expansion":null}],"children":[],"rendered":"\u001b[1m\u001b[33mwarning\u001b[0m\u001b[1m: function `host_get_terrain` is never used\u001b[0m\n \u001b[1m\u001b[94m--> \u001b[0msrc/lib.rs:29:8\n \u001b[1m\u001b[94m|\u001b[0m\n\u001b[1m\u001b[94m29\u001b[0m \u001b[1m\u001b[94m|\u001b[0m fn host_get_terrain(x: i32, z: i32) -> i32;\n \u001b[1m\u001b[94m|\u001b[0m \u001b[1m\u001b[33m^^^^^^^^^^^^^^^^\u001b[0m\n\n"}
{"$message_type":"diagnostic","message":"function `host_get_water` is never used","code":{"code":"dead_code","explanation":null},"level":"warning","spans":[{"file_name":"src/lib.rs","byte_start":1189,"byte_end":1203,"line_start":31,"line_end":31,"column_start":8,"column_end":22,"is_primary":true,"text":[{"text":" fn host_get_water(x: i32, z: i32) -> i32;","highlight_start":8,"highlight_end":22}],"label":null,"suggested_replacement":null,"suggestion_applicability":null,"expansion":null}],"children":[],"rendered":"\u001b[1m\u001b[33mwarning\u001b[0m\u001b[1m: function `host_get_water` is never used\u001b[0m\n \u001b[1m\u001b[94m--> \u001b[0msrc/lib.rs:31:8\n \u001b[1m\u001b[94m|\u001b[0m\n\u001b[1m\u001b[94m31\u001b[0m \u001b[1m\u001b[94m|\u001b[0m fn host_get_water(x: i32, z: i32) -> i32;\n \u001b[1m\u001b[94m|\u001b[0m \u001b[1m\u001b[33m^^^^^^^^^^^^^^\u001b[0m\n\n"}
{"$message_type":"diagnostic","message":"function `host_alloc_buffers` is never used","code":{"code":"dead_code","explanation":null},"level":"warning","spans":[{"file_name":"src/lib.rs","byte_start":1314,"byte_end":1332,"line_start":34,"line_end":34,"column_start":8,"column_end":26,"is_primary":true,"text":[{"text":" fn host_alloc_buffers() -> i32;","highlight_start":8,"highlight_end":26}],"label":null,"suggested_replacement":null,"suggestion_applicability":null,"expansion":null}],"children":[],"rendered":"\u001b[1m\u001b[33mwarning\u001b[0m\u001b[1m: function `host_alloc_buffers` is never used\u001b[0m\n \u001b[1m\u001b[94m--> \u001b[0msrc/lib.rs:34:8\n \u001b[1m\u001b[94m|\u001b[0m\n\u001b[1m\u001b[94m34\u001b[0m \u001b[1m\u001b[94m|\u001b[0m fn host_alloc_buffers() -> i32;\n \u001b[1m\u001b[94m|\u001b[0m \u001b[1m\u001b[33m^^^^^^^^^^^^^^^^^^\u001b[0m\n\n"}
{"$message_type":"diagnostic","message":"function `host_load_heightmap` is never used","code":{"code":"dead_code","explanation":null},"level":"warning","spans":[{"file_name":"src/lib.rs","byte_start":1350,"byte_end":1369,"line_start":35,"line_end":35,"column_start":8,"column_end":27,"is_primary":true,"text":[{"text":" fn host_load_heightmap() -> i32;","highlight_start":8,"highlight_end":27}],"label":null,"suggested_replacement":null,"suggestion_applicability":null,"expansion":null}],"children":[],"rendered":"\u001b[1m\u001b[33mwarning\u001b[0m\u001b[1m: function `host_load_heightmap` is never used\u001b[0m\n \u001b[1m\u001b[94m--> \u001b[0msrc/lib.rs:35:8\n \u001b[1m\u001b[94m|\u001b[0m\n\u001b[1m\u001b[94m35\u001b[0m \u001b[1m\u001b[94m|\u001b[0m fn host_load_heightmap() -> i32;\n \u001b[1m\u001b[94m|\u001b[0m \u001b[1m\u001b[33m^^^^^^^^^^^^^^^^^^^\u001b[0m\n\n"}
{"$message_type":"diagnostic","message":"function `host_load_terrain` is never used","code":{"code":"dead_code","explanation":null},"level":"warning","spans":[{"file_name":"src/lib.rs","byte_start":1387,"byte_end":1404,"line_start":36,"line_end":36,"column_start":8,"column_end":25,"is_primary":true,"text":[{"text":" fn host_load_terrain() -> i32;","highlight_start":8,"highlight_end":25}],"label":null,"suggested_replacement":null,"suggestion_applicability":null,"expansion":null}],"children":[],"rendered":"\u001b[1m\u001b[33mwarning\u001b[0m\u001b[1m: function `host_load_terrain` is never used\u001b[0m\n \u001b[1m\u001b[94m--> \u001b[0msrc/lib.rs:36:8\n \u001b[1m\u001b[94m|\u001b[0m\n\u001b[1m\u001b[94m36\u001b[0m \u001b[1m\u001b[94m|\u001b[0m fn host_load_terrain() -> i32;\n \u001b[1m\u001b[94m|\u001b[0m \u001b[1m\u001b[33m^^^^^^^^^^^^^^^^^\u001b[0m\n\n"}
{"$message_type":"diagnostic","message":"function `host_load_water` is never used","code":{"code":"dead_code","explanation":null},"level":"warning","spans":[{"file_name":"src/lib.rs","byte_start":1422,"byte_end":1437,"line_start":37,"line_end":37,"column_start":8,"column_end":23,"is_primary":true,"text":[{"text":" fn host_load_water() -> i32;","highlight_start":8,"highlight_end":23}],"label":null,"suggested_replacement":null,"suggestion_applicability":null,"expansion":null}],"children":[],"rendered":"\u001b[1m\u001b[33mwarning\u001b[0m\u001b[1m: function `host_load_water` is never used\u001b[0m\n \u001b[1m\u001b[94m--> \u001b[0msrc/lib.rs:37:8\n \u001b[1m\u001b[94m|\u001b[0m\n\u001b[1m\u001b[94m37\u001b[0m \u001b[1m\u001b[94m|\u001b[0m fn host_load_water() -> i32;\n \u001b[1m\u001b[94m|\u001b[0m \u001b[1m\u001b[33m^^^^^^^^^^^^^^^\u001b[0m\n\n"}
{"$message_type":"diagnostic","message":"function `host_store_heightmap` is never used","code":{"code":"dead_code","explanation":null},"level":"warning","spans":[{"file_name":"src/lib.rs","byte_start":1455,"byte_end":1475,"line_start":38,"line_end":38,"column_start":8,"column_end":28,"is_primary":true,"text":[{"text":" fn host_store_heightmap();","highlight_start":8,"highlight_end":28}],"label":null,"suggested_replacement":null,"suggestion_applicability":null,"expansion":null}],"children":[],"rendered":"\u001b[1m\u001b[33mwarning\u001b[0m\u001b[1m: function `host_store_heightmap` is never used\u001b[0m\n \u001b[1m\u001b[94m--> \u001b[0msrc/lib.rs:38:8\n \u001b[1m\u001b[94m|\u001b[0m\n\u001b[1m\u001b[94m38\u001b[0m \u001b[1m\u001b[94m|\u001b[0m fn host_store_heightmap();\n \u001b[1m\u001b[94m|\u001b[0m \u001b[1m\u001b[33m^^^^^^^^^^^^^^^^^^^^\u001b[0m\n\n"}
{"$message_type":"diagnostic","message":"function `host_store_terrain` is never used","code":{"code":"dead_code","explanation":null},"level":"warning","spans":[{"file_name":"src/lib.rs","byte_start":1486,"byte_end":1504,"line_start":39,"line_end":39,"column_start":8,"column_end":26,"is_primary":true,"text":[{"text":" fn host_store_terrain();","highlight_start":8,"highlight_end":26}],"label":null,"suggested_replacement":null,"suggestion_applicability":null,"expansion":null}],"children":[],"rendered":"\u001b[1m\u001b[33mwarning\u001b[0m\u001b[1m: function `host_store_terrain` is never used\u001b[0m\n \u001b[1m\u001b[94m--> \u001b[0msrc/lib.rs:39:8\n \u001b[1m\u001b[94m|\u001b[0m\n\u001b[1m\u001b[94m39\u001b[0m \u001b[1m\u001b[94m|\u001b[0m fn host_store_terrain();\n \u001b[1m\u001b[94m|\u001b[0m \u001b[1m\u001b[33m^^^^^^^^^^^^^^^^^^\u001b[0m\n\n"}
{"$message_type":"diagnostic","message":"function `host_store_water` is never used","code":{"code":"dead_code","explanation":null},"level":"warning","spans":[{"file_name":"src/lib.rs","byte_start":1515,"byte_end":1531,"line_start":40,"line_end":40,"column_start":8,"column_end":24,"is_primary":true,"text":[{"text":" fn host_store_water();","highlight_start":8,"highlight_end":24}],"label":null,"suggested_replacement":null,"suggestion_applicability":null,"expansion":null}],"children":[],"rendered":"\u001b[1m\u001b[33mwarning\u001b[0m\u001b[1m: function `host_store_water` is never used\u001b[0m\n \u001b[1m\u001b[94m--> \u001b[0msrc/lib.rs:40:8\n \u001b[1m\u001b[94m|\u001b[0m\n\u001b[1m\u001b[94m40\u001b[0m \u001b[1m\u001b[94m|\u001b[0m fn host_store_water();\n \u001b[1m\u001b[94m|\u001b[0m \u001b[1m\u001b[33m^^^^^^^^^^^^^^^^\u001b[0m\n\n"}
{"$message_type":"diagnostic","message":"13 warnings emitted","code":null,"level":"warning","spans":[],"children":[],"rendered":"\u001b[1m\u001b[33mwarning\u001b[0m\u001b[1m: 13 warnings emitted\u001b[0m\n\n"}

View file

@ -0,0 +1,5 @@
/storage/project/Terrafier/plugins/example-noisemap/target/wasm32-unknown-unknown/debug/deps/terrafier_plugin_example_noisemap-14d72cbe84c58ee8.d: src/lib.rs
/storage/project/Terrafier/plugins/example-noisemap/target/wasm32-unknown-unknown/debug/deps/libterrafier_plugin_example_noisemap-14d72cbe84c58ee8.rmeta: src/lib.rs
src/lib.rs:

18
scripts/example.rhai Normal file
View file

@ -0,0 +1,18 @@
// Terrafier script example — creates a world with noise terrain
let world = new_world("scripted_world", 42);
// Generate noise terrain
generate_heightmap_noise(world, 12345, 16.0, 6, "overworld");
// Apply some operations
apply_smooth(world, 2, 5.0, "overworld");
apply_erode(world, 1, 4.0, "overworld");
// Generate layers (caves, rivers, biomes, etc.)
generate_layers(world, "overworld");
// Save the world
save_world(world, "scripted_world.tfw");
print(info(world));
print("Done! World saved to scripted_world.tfw");