mirror of
https://github.com/horizon-rust/horizon-mods.git
synced 2026-11-07 11:05:58 -10:00
Compare commits
42
Commits
No files matched your search
@@ -0,0 +1,117 @@
|
||||
# Horizon's own native mods (native/<name>/): each changed folder's tests and .hnmod, and the game with it applied
|
||||
# on the Horizon it was tested on; every mod together, as a player's set. Every Monday, all of them on the newest
|
||||
# Nightly, to catch a change of the hook points early.
|
||||
name: native
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths: ["native/**", ".github/workflows/native.yml"]
|
||||
push:
|
||||
branches: [main]
|
||||
paths: ["native/**", ".github/workflows/native.yml"]
|
||||
schedule:
|
||||
- cron: "17 5 * * 1"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
horizon:
|
||||
description: "Horizon commit, tag, or `latest` (the newest Nightly); empty: the mods' tested commit"
|
||||
default: latest
|
||||
permissions:
|
||||
contents: read
|
||||
concurrency:
|
||||
group: native-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
env:
|
||||
# The Rust the launcher builds native mods with (launcher/src/rust_toolchain.rs).
|
||||
RUST: "1.99.1"
|
||||
jobs:
|
||||
changes:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
mods: ${{ steps.mods.outputs.mods }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- id: mods
|
||||
env:
|
||||
BASE: ${{ github.event.pull_request.base.sha || github.event.before }}
|
||||
EVENT: ${{ github.event_name }}
|
||||
run: |
|
||||
all=$(ls -d native/*/compatibility.json | cut -d/ -f2 | jq -R . | jq -sc .)
|
||||
if [ "$EVENT" = schedule ] || [ "$EVENT" = workflow_dispatch ] || [ -z "$BASE" ] \
|
||||
|| ! git cat-file -e "$BASE" 2>/dev/null; then
|
||||
echo "mods=$all" >> "$GITHUB_OUTPUT"; exit 0
|
||||
fi
|
||||
changed=$(git diff --name-only "$BASE" HEAD)
|
||||
# The shared tools or this workflow: every mod.
|
||||
if grep -qE '^(native/tools/|\.github/workflows/native\.yml)' <<< "$changed"; then
|
||||
echo "mods=$all" >> "$GITHUB_OUTPUT"; exit 0
|
||||
fi
|
||||
mods=$(grep -oE '^native/[^/]+/' <<< "$changed" | cut -d/ -f2 | sort -u \
|
||||
| while read -r m; do [ -f "native/$m/compatibility.json" ] && echo "$m"; done | jq -R . | jq -sc .)
|
||||
echo "mods=$mods" >> "$GITHUB_OUTPUT"
|
||||
mod:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.mods != '[]'
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
mod: ${{ fromJson(needs.changes.outputs.mods) }}
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: native/${{ matrix.mod }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- run: rustup toolchain install $RUST --profile minimal --component rustfmt --component clippy && rustup default $RUST
|
||||
- run: cargo fmt --check
|
||||
- run: cargo clippy --all-targets --locked -- -D warnings
|
||||
- run: cargo test --locked
|
||||
- name: Package the .hnmod and check what it holds
|
||||
working-directory: .
|
||||
run: |
|
||||
python3 native/tools/native.py package ${{ matrix.mod }}
|
||||
python3 - <<'PY'
|
||||
import json, zipfile, glob
|
||||
path, = glob.glob("native/target/*.hnmod")
|
||||
names = zipfile.ZipFile(path).namelist()
|
||||
manifest = json.loads(zipfile.ZipFile(path).read("horizon-native.json"))
|
||||
assert manifest["hooks"] >= 1, manifest
|
||||
assert all(n == "horizon-native.json" or (n.startswith(f"mixins/{manifest['id']}/") and n.endswith((".rs", ".toml"))) for n in names), names
|
||||
print(path, manifest)
|
||||
PY
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: hnmod-${{ matrix.mod }}
|
||||
path: native/target/*.hnmod
|
||||
horizon:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.mods != '[]'
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
mod: ${{ fromJson(needs.changes.outputs.mods) }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- run: rustup toolchain install $RUST --profile minimal && rustup default $RUST
|
||||
- run: sudo apt-get update && sudo apt-get install -y libasound2-dev libudev-dev pkg-config
|
||||
- name: Apply on Horizon and check the game
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
HORIZON: ${{ github.event_name == 'schedule' && 'latest' || inputs.horizon || '' }}
|
||||
run: python3 native/tools/native.py check ${{ matrix.mod }} ${HORIZON:+--horizon "$HORIZON"}
|
||||
together:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.mods != '[]'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- run: rustup toolchain install $RUST --profile minimal && rustup default $RUST
|
||||
- run: sudo apt-get update && sudo apt-get install -y libasound2-dev libudev-dev pkg-config
|
||||
- name: Every native mod applied together, on Horizon
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
HORIZON: ${{ github.event_name == 'schedule' && 'latest' || inputs.horizon || '' }}
|
||||
run: python3 native/tools/native.py check all ${HORIZON:+--horizon "$HORIZON"}
|
||||
@@ -1,3 +1,5 @@
|
||||
/files/
|
||||
/site/
|
||||
/bootstrap/
|
||||
/native/target/
|
||||
/native/*/target/
|
||||
@@ -64,6 +64,9 @@ why.
|
||||
| `.github/workflows/check.yml` | A pull request's checks |
|
||||
| `.github/workflows/publish.yml` | On merge: releases, then `index.json` on Pages |
|
||||
| `.github/workflows/cleanup.yml` | Weekly: staging files nobody needs any more |
|
||||
| `.github/workflows/native.yml` | Horizon's native mods: each changed one's tests, `.hnmod` and game check, all of them together, and weekly on the newest Nightly |
|
||||
| `native/<name>/` | Horizon's own native mods (Zoom, FPS Counter, Keystrokes, Minimap, Recipes): the source of their `.hnmod` files |
|
||||
| `native/tools/` | Shared by them: apply on Horizon, check, build, package, prove (`native/tools/native.py`) |
|
||||
| `scripts/` | What the workflows share |
|
||||
|
||||
The format of the entries and of `index.json` is
|
||||
@@ -72,3 +75,20 @@ the checks are `cargo horizon-mod check` and `cargo horizon-mod registry`, from
|
||||
repository variable `HORIZON_REV` (else `main`).
|
||||
|
||||
The index: `https://horizon-rust.github.io/horizon-mods/index.json`.
|
||||
|
||||
## Horizon's native mods
|
||||
|
||||
`native/<name>/` holds the source of Horizon's official mods, as
|
||||
[horizon-mixins](https://github.com/horizon-rust/horizon-mixins) bundles on Horizon's native hook points
|
||||
([docs/native-mods.md](https://github.com/horizon-rust/horizon/blob/main/docs/native-mods.md)). Each folder keeps the
|
||||
history of the repository it came from. A mod builds on any Horizon whose hook points are at least the version its
|
||||
`compatibility.json` names; `horizon_revision` is the commit it was last tested on.
|
||||
|
||||
```sh
|
||||
python3 native/tools/native.py check zoom # apply on the tested Horizon and cargo check the game
|
||||
python3 native/tools/native.py check all # every native mod together
|
||||
python3 native/tools/native.py package all # native/target/<id>-<version>.hnmod, for a registry entry
|
||||
python3 native/tools/native.py proof zoom --resources /path/to/.local/horizon-client/26.3
|
||||
```
|
||||
|
||||
To make your own, start from [horizon-native-mod-template](https://github.com/horizon-rust/horizon-native-mod-template).
|
||||
@@ -0,0 +1,27 @@
|
||||
id = "biomecompass"
|
||||
kind = "native-mod"
|
||||
name = "BiomeCompass"
|
||||
summary = "Craft a Biome Compass, pick a biome and its needle points to the nearest one."
|
||||
description = """
|
||||
**A native Horizon mod**: the launcher builds it into the game (Settings → Game → Native mods, then Build). Native mods are not sandboxed and run with your account's rights, so read the source before you build it. Pinned to Horizon 142388e and horizon-mixins 0d6b103.
|
||||
|
||||
Craft a **Biome Compass** (a compass with a sapling on each of its four sides, any kinds), right-click it and pick a biome from the list (with a search box). The world generator is searched around you, a little every tick, and the compass's needle then points to the nearest one, with its distance and direction on the action bar.
|
||||
|
||||
- Works in single player, on LAN and on a server running the native build, for vanilla clients too (the picker is a vanilla dialog).
|
||||
- `/biomecompass find|list|status|stop|tp` as well; a small HUD box is optional.
|
||||
- Settings: search radius, sample spacing, per-tick budget, HUD.
|
||||
|
||||
Inspired by [Nature's Compass](https://modrinth.com/mod/natures-compass); not affiliated, no code from it."""
|
||||
authors = ["d3grando"]
|
||||
owners = ["d3grando"]
|
||||
license = "MIT"
|
||||
source = "https://github.com/d3grando/biomecompass"
|
||||
icon = "https://raw.githubusercontent.com/d3grando/biomecompass/main/icon.png"
|
||||
|
||||
[[versions]]
|
||||
version = "0.2.2"
|
||||
published = "2026-10-29"
|
||||
game = "26.3"
|
||||
file = "biomecompass-0.2.2.hnmod"
|
||||
sha256 = "d9a4aedcf22318774e1ac6bed6f1e00aca13a38c8dc3426d5a7800e4d8c61bf5"
|
||||
changelog = "First release."
|
||||
+10
-10
@@ -1,22 +1,22 @@
|
||||
id = "blockscope"
|
||||
kind = "mod"
|
||||
name = "Blockscope"
|
||||
summary = "The block under your crosshair: its name, id and distance, in a box beside the crosshair."
|
||||
description = """
|
||||
Jade-style block info for Horizon: the block under your crosshair, its name in your language, its block id and how far it is, in a small box beside the crosshair.
|
||||
|
||||
- Nothing is drawn when you look at no block, while a screen is open, or with the HUD hidden (F1).
|
||||
- Reaches 20 blocks and flips to the left near the screen's right edge.
|
||||
|
||||
It reads the player and one crosshair raycast, draws on the HUD, and reads the item table for block names. No chat, no storage, no commands.
|
||||
|
||||
Hardness, the right tool, block states and entities are out of reach in mod API 0.1: they need the future `world-read` and crosshair `target` interfaces."""
|
||||
summary = "The block under your crosshair: its name, id, distance and icon, with reach, placement and opacity on its own settings page. A Jade-style tooltip for Horizon."
|
||||
description = "Jade-style block info for Horizon: the block under your crosshair, its name in your language, its block id, how far it is and its icon."
|
||||
authors = ["Domanffe"]
|
||||
owners = ["Domanffe"]
|
||||
license = "MIT"
|
||||
source = "https://github.com/Domanffe/blockscope"
|
||||
icon = "https://raw.githubusercontent.com/Domanffe/blockscope/main/docs/icon.png"
|
||||
|
||||
[[versions]]
|
||||
version = "0.2.0"
|
||||
published = "2026-10-29"
|
||||
game = "26.3"
|
||||
file = "blockscope-0.2.0.hmod"
|
||||
sha256 = "dcb8a3d3eefe37ebc7fd1886412dc9799c5fa4dee88fd419e7f46a712df00c12"
|
||||
changelog = "Reach, placement, background, coordinates and the item icon on its own settings page (Options > Mods > Blockscope), kept between games."
|
||||
|
||||
[[versions]]
|
||||
version = "0.1.0"
|
||||
published = "2026-10-27"
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
id = "carryblock"
|
||||
kind = "native-mod"
|
||||
name = "Carry Block"
|
||||
summary = "Sneak and right-click with an empty hand to pick up a chest (with its items) or a small mob, carry it and put it down."
|
||||
description = """
|
||||
**A native Horizon mod**: the launcher builds it into the game (Settings → Game → Native mods, then Build). Native mods are not sandboxed and run with your account's rights, so read the source before you build it. Pinned to Horizon 142388e and horizon-mixins 0d6b103.
|
||||
|
||||
Sneak and right-click with an empty hand to pick up a block with its contents (chests, furnaces, barrels...) or a small, friendly mob, and carry it; right-click to put it down where you want, with everything it held.
|
||||
|
||||
- Slowness while carrying; a small HUD box shows the load.
|
||||
- Never an item: nothing to duplicate. On death the load is put down nearby, or dropped as its item with its contents.
|
||||
- Follows the game's rules: spawn protection, world border, reach, adventure mode, game master blocks; no pick-up of a container someone has open, a mob being traded with, a monster, a boss or another player's pet. Spawners and similar are denied by default.
|
||||
- Deny lists, survival/creative rules and slowness are settings (Options → Mods → Carry Block).
|
||||
|
||||
Inspired by [Carry On](https://modrinth.com/mod/carry-on); not affiliated, no code from it."""
|
||||
authors = ["d3grando"]
|
||||
owners = ["d3grando"]
|
||||
license = "MIT"
|
||||
source = "https://github.com/d3grando/carryblock"
|
||||
icon = "https://raw.githubusercontent.com/d3grando/carryblock/main/icon.png"
|
||||
|
||||
[[versions]]
|
||||
version = "0.1.2"
|
||||
published = "2026-10-29"
|
||||
game = "26.3"
|
||||
file = "carryblock-0.1.2.hnmod"
|
||||
sha256 = "9a6ebd26f640a477815862eb36846fe3289046d0f4c06ef3a815f463ca9a3701"
|
||||
changelog = "First release."
|
||||
@@ -0,0 +1,62 @@
|
||||
id = "classic_zoom"
|
||||
kind = "mod"
|
||||
name = "Classic Zoom"
|
||||
summary = "Hold V to zoom 3x, scroll for more, release to snap back. Instant, no settings, inspired by WI Zoom."
|
||||
description = """
|
||||
## Controls
|
||||
|
||||
| Action | Result |
|
||||
|---|---|
|
||||
| Hold **V** | Zoom in 3x |
|
||||
| Scroll while holding V | Zoom from 1x to 10x, x1.1 per wheel step |
|
||||
| Release V | Back to normal view at once |
|
||||
|
||||
While V is held, the mouse wheel zooms instead of switching hotbar slots, and turning slows down in step with the zoom so aiming stays steady. Rebind the key on the mod's page in Options > Mods.
|
||||
|
||||
## How it differs from Horizon's own Zoom
|
||||
|
||||
| | Horizon Zoom | Classic Zoom |
|
||||
|---|---|---|
|
||||
| Default key | Z | V |
|
||||
| Starting zoom | 4x (configurable) | 3x |
|
||||
| Range | 1.25x to 10x | 1x to 10x |
|
||||
| Wheel step | x1.25 | x1.1, finer |
|
||||
| Transition | Smooth by default | Instant |
|
||||
| Settings page | Yes | None |
|
||||
|
||||
Both can be installed at once: they use different keys.
|
||||
|
||||
## Notes
|
||||
|
||||
- Horizon caps zoom at 10x, so there is no 50x as in WI Zoom.
|
||||
- Written from scratch for Horizon's mod API. Not affiliated with Wurst-Imperium.
|
||||
- Some servers forbid zoom mods: check the rules before you join.
|
||||
|
||||
Source and issues: [github.com/SU4G3/classic-zoom](https://github.com/SU4G3/classic-zoom)"""
|
||||
authors = ["Surge"]
|
||||
owners = ["SU4G3"]
|
||||
license = "GPL-3.0-or-later"
|
||||
source = "https://github.com/SU4G3/classic-zoom"
|
||||
icon = "https://github.com/SU4G3/classic-zoom/blob/main/.github/icon.png?raw=true"
|
||||
key = "RWTOxMcIG6MtC0QQnhWEMC+nLKJ6+u4trV/rF+4juSRuow8W4b5P+CPS"
|
||||
|
||||
[[versions]]
|
||||
version = "1.0.0"
|
||||
published = "2026-10-29"
|
||||
game = "26.3"
|
||||
file = "classic_zoom-1.0.0.hmod"
|
||||
sha256 = "d8bc299202d9760299a3901841adef950d46cfcc1a54e0729379d24885e9ca5d"
|
||||
signature = """
|
||||
untrusted comment: signature from minisign secret key
|
||||
RUTOxMcIG6MtC63o8q5cdLDamPDTVjscmZ1LMdBd8xhKmsCNy6c77sBps3h65Z2/AO+apNyH6zkbnViYtwI3eAQjgrhJvR88cQs=
|
||||
trusted comment: timestamp:1793265658\tfile:classic_zoom-1.0.0.hmod\thashed
|
||||
QLysIbvTRiUzChl9NiUchhmO1Nn4nh4HLkJwtseTccYm3YsKz4nVieLsC6tIY6xItPGDT/43EWu89GhlnO60DA=="""
|
||||
changelog = """
|
||||
Initial release.
|
||||
|
||||
- Hold V to zoom in 3x; release to snap back to normal view.
|
||||
- Scroll while holding V to zoom from 1x to 10x (x1.1 per wheel step); the hotbar doesn't move while zooming.
|
||||
- Instant zoom with no transition, in the style of WI Zoom.
|
||||
- Mouse turning slows down in step with the zoom for steady aiming.
|
||||
- Key rebindable on the mod's page in Options > Mods.
|
||||
- Permissions: render (camera zoom) and input (the zoom key and mouse wheel) only."""
|
||||
@@ -0,0 +1,39 @@
|
||||
id = "compass_bar"
|
||||
kind = "mod"
|
||||
name = "Compass Enhanced"
|
||||
summary = "A compass bar with degrees and colored directions, plus death points that guide you back."
|
||||
description = """
|
||||
A compass bar at the top of your screen that shows where you are facing, in degrees, and slides smoothly as you turn.
|
||||
|
||||
## Features
|
||||
- Compass letters (N, NE, E, SE, S, SW, W, NW) in a color you choose
|
||||
- Death points: every time you die, a marker stays on the bar with the distance to where you died
|
||||
- Reaching a death point turns it into an X with a countdown, or look at it and press X to dismiss it
|
||||
- Arrows with the distance at the edge of the bar for death points out of view
|
||||
- Optional background box and four sizes (75% to 150%)
|
||||
- No commands
|
||||
|
||||
Settings are in Options → Mods → Compass Bar. Death points are not saved: they vanish when you close the game."""
|
||||
authors = ["Isaac"]
|
||||
owners = ["Fernandes585"]
|
||||
license = "MIT"
|
||||
source = "https://github.com/Fernandes585/Compass-Enhanced"
|
||||
key = "RWScU5xtTnTnu55tXSTPbhM2BQ6KG2FLeMD9aTeX111NlQPh7cCafm1w"
|
||||
|
||||
[[versions]]
|
||||
version = "0.1.0"
|
||||
published = "2026-10-28"
|
||||
game = "26.3"
|
||||
file = "compass_bar-0.1.0.hmod"
|
||||
sha256 = "bb8f191f89f5ea963301fd2d28315b251dfb8df9656b640975eddffaec3e6b00"
|
||||
signature = """
|
||||
untrusted comment: signature from minisign secret key\r
|
||||
RUScU5xtTnTnuz0T7EECJHdaPC5PHAOud+04Uwen4mw75K38TKOCnK9eCKn44pX113qFMvkrP+P4CwF9FJBFX5qws5BedBPbpA0=\r
|
||||
trusted comment: timestamp:1793221575\tfile:target/horizon-mod/compass_bar.hmod\thashed\r
|
||||
DedLxzyaxaXaNO8y8pICM4Th3nL0N4oxamDGm0eoQb+6UDBj6pLXbtQLxqnMrbDQYmCE2gX6PGS86G1C33GGDg=="""
|
||||
changelog = """
|
||||
First release.
|
||||
|
||||
- Compass bar with degrees and compass letters (N, NE, E, SE, S, SW, W, NW)
|
||||
- Death points with distance, arrival countdown and a key to dismiss them (X by default)
|
||||
- Settings: bar on/off, background, scale, letter color, arrival distance, X time and death points kept"""
|
||||
@@ -0,0 +1,26 @@
|
||||
id = "enchantmenthints"
|
||||
kind = "native-mod"
|
||||
name = "Enchantment Hints"
|
||||
summary = "A short description under every enchantment in item tooltips, in English and Russian."
|
||||
description = """
|
||||
**A native Horizon mod**: the launcher builds it into the game (Settings → Game → Native mods, then Build). Native mods are not sandboxed and run with your account's rights, so read the source before you build it. Pinned to Horizon 142388e and horizon-mixins 0d6b103.
|
||||
|
||||
Every enchantment on books and gear gets a one-line description in its tooltip: what it does, in plain words. Our own text for every 26.3 enchantment, in English and Russian.
|
||||
|
||||
- Always, or only while Shift is held (with a reminder line).
|
||||
- Colour and line width are settings.
|
||||
|
||||
Settings: Options → Mods → Enchantment Hints. Inspired by [Enchantment Descriptions](https://modrinth.com/mod/enchantment-descriptions); not affiliated, no code or text from it."""
|
||||
authors = ["d3grando"]
|
||||
owners = ["d3grando"]
|
||||
license = "MIT"
|
||||
source = "https://github.com/d3grando/enchantmenthints"
|
||||
icon = "https://raw.githubusercontent.com/d3grando/enchantmenthints/main/icon.png"
|
||||
|
||||
[[versions]]
|
||||
version = "0.1.0"
|
||||
published = "2026-10-29"
|
||||
game = "26.3"
|
||||
file = "enchantmenthints-0.1.0.hnmod"
|
||||
sha256 = "6f1b80a7c22e02b086d5eea2ed6f7c5481b3e40d6a974bf9e382a3df4a2de9df"
|
||||
changelog = "First release."
|
||||
+15
-2
@@ -1,5 +1,5 @@
|
||||
id = "fps_overlay"
|
||||
kind = "mod"
|
||||
kind = "native-mod"
|
||||
name = "FPS Counter"
|
||||
summary = "Frames per second in a corner of the screen, with the minimum and the 1% low."
|
||||
description = """
|
||||
@@ -10,11 +10,24 @@ Horizon's own FPS counter, formerly an extra in Options > Horizon Extras.
|
||||
- Settings: Options > Mods > FPS Counter > Settings (corner; minimum and 1% low).
|
||||
|
||||
It reads the game's own frame counter and draws on the HUD: nothing else.
|
||||
|
||||
Now a native mod (2.0.0): built into the game by the launcher, on the Horizon you play, like any native mod
|
||||
(Settings → Game → Native mods (experimental), then Build). Your settings carry over from 1.0.0. Turn it off in
|
||||
Options > Mods: it rests at once, and the next build leaves it out. Its source is
|
||||
[horizon-mods/native/fps-overlay](https://github.com/horizon-rust/horizon-mods/tree/main/native/fps-overlay).
|
||||
"""
|
||||
authors = ["Horizon"]
|
||||
owners = ["ludovic111"]
|
||||
license = "MIT"
|
||||
source = "https://github.com/horizon-rust/horizon/tree/main/mods/official/fps-overlay"
|
||||
source = "https://github.com/horizon-rust/horizon-mods/tree/main/native/fps-overlay"
|
||||
|
||||
[[versions]]
|
||||
version = "2.0.0"
|
||||
published = "2026-10-29"
|
||||
game = "26.3"
|
||||
file = "fps_overlay-2.0.0.hnmod"
|
||||
sha256 = "bedb670e2490ce8239906c86eabd3a0c131ef5037b2937607231bdbdba38c171"
|
||||
changelog = "Native: built into the game by the launcher on Horizon's native hook points; settings carry over; turn it off in Options > Mods."
|
||||
|
||||
[[versions]]
|
||||
version = "1.0.0"
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
id = "itemnew"
|
||||
kind = "native-mod"
|
||||
name = "Item New"
|
||||
summary = "Newly picked-up items glow in your hotbar and inventory until you look at them."
|
||||
description = """
|
||||
**A native Horizon mod**: the launcher builds it into the game (Settings → Game → Native mods, then Build). Native mods are not sandboxed and run with your account's rights, so read the source before you build it. Pinned to Horizon 142388e and horizon-mixins 0d6b103.
|
||||
|
||||
Items you just got (picked up, crafted, taken from a chest, given) get a soft glow in the hotbar and in inventory and container screens, until you point at them or close the screen.
|
||||
|
||||
- Styles: glow, glow with a star, by rarity colour.
|
||||
- Which screens, and when a mark clears, are settings; `/itemnew` shows or clears the marks.
|
||||
|
||||
Settings: Options → Mods → Item New. Inspired by [Item Highlighter](https://modrinth.com/mod/item-highlighter); not affiliated, no code from it."""
|
||||
authors = ["d3grando"]
|
||||
owners = ["d3grando"]
|
||||
license = "MIT"
|
||||
source = "https://github.com/d3grando/itemnew"
|
||||
icon = "https://raw.githubusercontent.com/d3grando/itemnew/main/icon.png"
|
||||
|
||||
[[versions]]
|
||||
version = "0.1.1"
|
||||
published = "2026-10-29"
|
||||
game = "26.3"
|
||||
file = "itemnew-0.1.1.hnmod"
|
||||
sha256 = "cdc68c9911736703ebc4b8b81895a719edd74ba9d08fe01ddb34fe09b8d490e9"
|
||||
changelog = "First release."
|
||||
+15
-2
@@ -1,5 +1,5 @@
|
||||
id = "keystrokes"
|
||||
kind = "mod"
|
||||
kind = "native-mod"
|
||||
name = "Keystrokes"
|
||||
summary = "Your movement keys, mouse buttons and space bar in a corner, lit while held, with clicks per second."
|
||||
description = """
|
||||
@@ -10,11 +10,24 @@ Horizon's own keystrokes overlay, formerly an extra in Options > Horizon Extras.
|
||||
- Settings: Options > Mods > Keystrokes > Settings (corner; clicks per second).
|
||||
|
||||
It reads which keys you hold and your clicks, and draws on the HUD: nothing else.
|
||||
|
||||
Now a native mod (2.0.0): built into the game by the launcher, on the Horizon you play, like any native mod
|
||||
(Settings → Game → Native mods (experimental), then Build). Your settings carry over from 1.0.0. Turn it off in
|
||||
Options > Mods: it rests at once, and the next build leaves it out. Its source is
|
||||
[horizon-mods/native/keystrokes](https://github.com/horizon-rust/horizon-mods/tree/main/native/keystrokes).
|
||||
"""
|
||||
authors = ["Horizon"]
|
||||
owners = ["ludovic111"]
|
||||
license = "MIT"
|
||||
source = "https://github.com/horizon-rust/horizon/tree/main/mods/official/keystrokes"
|
||||
source = "https://github.com/horizon-rust/horizon-mods/tree/main/native/keystrokes"
|
||||
|
||||
[[versions]]
|
||||
version = "2.0.0"
|
||||
published = "2026-10-29"
|
||||
game = "26.3"
|
||||
file = "keystrokes-2.0.0.hnmod"
|
||||
sha256 = "bed34d2f8049e92c52cd7c91c0c4c8c9d165faaf0183500cf4021d9e04783a7d"
|
||||
changelog = "Native: built into the game by the launcher on Horizon's native hook points; settings carry over; turn it off in Options > Mods."
|
||||
|
||||
[[versions]]
|
||||
version = "1.0.0"
|
||||
|
||||
+15
-2
@@ -1,5 +1,5 @@
|
||||
id = "minimap"
|
||||
kind = "mod"
|
||||
kind = "native-mod"
|
||||
name = "Minimap"
|
||||
summary = "A map of the world around you in a corner of the screen."
|
||||
description = """
|
||||
@@ -10,11 +10,24 @@ Horizon's own minimap, formerly an extra in Options > Horizon Extras.
|
||||
- Settings: Options > Mods > Minimap > Settings (corner; size; square or round; zoom, 0.5 to 4 blocks per pixel; turn with the player).
|
||||
|
||||
It shows a map in a corner of the screen: nothing else.
|
||||
|
||||
Now a native mod (2.0.0): built into the game by the launcher, on the Horizon you play, like any native mod
|
||||
(Settings → Game → Native mods (experimental), then Build). Your settings carry over from 1.0.0. Turn it off in
|
||||
Options > Mods: it rests at once, and the next build leaves it out. Its source is
|
||||
[horizon-mods/native/minimap](https://github.com/horizon-rust/horizon-mods/tree/main/native/minimap).
|
||||
"""
|
||||
authors = ["Horizon"]
|
||||
owners = ["ludovic111"]
|
||||
license = "MIT"
|
||||
source = "https://github.com/horizon-rust/horizon/tree/main/mods/official/minimap"
|
||||
source = "https://github.com/horizon-rust/horizon-mods/tree/main/native/minimap"
|
||||
|
||||
[[versions]]
|
||||
version = "2.0.0"
|
||||
published = "2026-10-29"
|
||||
game = "26.3"
|
||||
file = "minimap-2.0.0.hnmod"
|
||||
sha256 = "6f5dbca0699562b088a04b3f5abdc1b7faf8da51257a96da0a4ed639c6644ed9"
|
||||
changelog = "Native: built into the game by the launcher on Horizon's native hook points; settings carry over; turn it off in Options > Mods."
|
||||
|
||||
[[versions]]
|
||||
version = "1.0.0"
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
id = "mousetricks"
|
||||
kind = "native-mod"
|
||||
name = "Mouse Tricks"
|
||||
summary = "Inventory mouse tricks: drag to spread or gather, the wheel moves items one by one, and R sorts."
|
||||
description = """
|
||||
**A native Horizon mod**: the launcher builds it into the game (Settings → Game → Native mods, then Build). Native mods are not sandboxed and run with your account's rights, so read the source before you build it. Pinned to Horizon 142388e and horizon-mixins 0d6b103.
|
||||
|
||||
- **Right-drag**: one item into each slot you pass.
|
||||
- **Left-drag**: gather the same items; with Shift, quick-move many stacks.
|
||||
- **Mouse wheel** over a slot: move items one at a time between your inventory and a container.
|
||||
- **Sort** (R, or middle click on an empty spot): merges partial stacks and sorts the part under the mouse (a chest, or your main inventory; the hotbar only if you ask). Creative-tab order, by name or by count. Items with other names, enchantments or damage never merge; bundles stay put.
|
||||
|
||||
Everything goes through the game's own container clicks, so servers accept it. Each trick is a setting (Options → Mods → Mouse Tricks). Inspired by [Mouse Tweaks](https://modrinth.com/mod/mouse-tweaks); not affiliated, no code from it."""
|
||||
authors = ["d3grando"]
|
||||
owners = ["d3grando"]
|
||||
license = "MIT"
|
||||
source = "https://github.com/d3grando/mousetricks"
|
||||
icon = "https://raw.githubusercontent.com/d3grando/mousetricks/main/icon.png"
|
||||
|
||||
[[versions]]
|
||||
version = "0.2.0"
|
||||
published = "2026-10-29"
|
||||
game = "26.3"
|
||||
file = "mousetricks-0.2.0.hnmod"
|
||||
sha256 = "48c60955371963f8e320a9efbe8285bb276d255c29178a8a021e7e2b47535cbe"
|
||||
changelog = "First release."
|
||||
@@ -0,0 +1,27 @@
|
||||
id = "smartf3"
|
||||
kind = "native-mod"
|
||||
name = "SmartF3"
|
||||
summary = "A tidier, configurable F3 debug screen: the same information in modules you can colour, reorder and turn off."
|
||||
description = """
|
||||
**A native Horizon mod**: the launcher builds it into the game (Settings → Game → Native mods, then Build). Native mods are not sandboxed and run with your account's rights, so read the source before you build it. Pinned to Horizon 142388e and horizon-mixins 0d6b103.
|
||||
|
||||
The F3 debug screen, grouped into 14 modules: FPS, server, coordinates, chunk, facing, world, biome, light, chart keys, system, rendering, and the targeted block, fluid and entity.
|
||||
|
||||
- Each module: on/off, label and value colours, left or right column, order.
|
||||
- Text shadow, background (vanilla, dark, none), gap between modules, compact layout.
|
||||
- The vanilla F3 is one click (or `/smartf3 off`) away; the F3 charts and keys work as in vanilla.
|
||||
|
||||
Settings: Options → Mods → SmartF3. Inspired by [BetterF3](https://modrinth.com/mod/betterf3); not affiliated, no code from it."""
|
||||
authors = ["d3grando"]
|
||||
owners = ["d3grando"]
|
||||
license = "MIT"
|
||||
source = "https://github.com/d3grando/smartf3"
|
||||
icon = "https://raw.githubusercontent.com/d3grando/smartf3/main/icon.png"
|
||||
|
||||
[[versions]]
|
||||
version = "0.1.0"
|
||||
published = "2026-10-29"
|
||||
game = "26.3"
|
||||
file = "smartf3-0.1.0.hnmod"
|
||||
sha256 = "666fc77a12411c8b525dca3d3d478595e760b73c48b62b936fb6026419547929"
|
||||
changelog = "First release."
|
||||
@@ -0,0 +1,27 @@
|
||||
id = "treefall"
|
||||
kind = "native-mod"
|
||||
name = "TreeFall"
|
||||
summary = "Cut one log of a natural tree with an axe and the whole tree comes down."
|
||||
description = """
|
||||
**A native Horizon mod** (built into the game by the launcher; native mods are not sandboxed, so read the source before you build it).
|
||||
|
||||
Cut one log of a natural tree with an axe and the whole tree comes down. Every log is broken through the game's ordinary mining path, so drops, statistics, hunger and axe wear are exactly as if you had chopped each log yourself.
|
||||
|
||||
- Sneak to cut a single log; log cabins (logs without natural leaves) are never felled.
|
||||
- **Settings page** (Options > Mods > TreeFall): which tools fell (axe / any tool / anything), sneaking, largest tree, leaves needed, cut time (as one log, or longer for bigger trees), tool wear (every log / once / a factor / none), keep durability, hunger, drops at the stump, replant a sapling, break the leaves, huge mushrooms.
|
||||
- `/treefall` on/off per player; LAN guests too.
|
||||
|
||||
In the spirit of FallingTree; not affiliated, no code from it. Pinned to Horizon 142388e and horizon-mixins 0d6b103."""
|
||||
authors = ["d3grando"]
|
||||
owners = ["d3grando"]
|
||||
license = "MIT"
|
||||
source = "https://github.com/d3grando/treefall"
|
||||
icon = "https://raw.githubusercontent.com/d3grando/treefall/main/icon.png"
|
||||
|
||||
[[versions]]
|
||||
version = "0.3.0"
|
||||
published = "2026-10-29"
|
||||
game = "26.3"
|
||||
file = "treefall-0.3.0.hnmod"
|
||||
sha256 = "6f7feea8d0f55291953476b272f00cfc4be42d64972e7efefeb3df47c9dae8ce"
|
||||
changelog = "First registry release. A settings page in Options > Mods; the cut can take longer for bigger trees (and letting go cancels it, in single player too); trees come down from the top and leave a stump when the axe would wear out; replanting, drops at the stump, leaves and huge mushrooms are options."
|
||||
+15
-2
@@ -1,5 +1,5 @@
|
||||
id = "zoom"
|
||||
kind = "mod"
|
||||
kind = "native-mod"
|
||||
name = "Zoom"
|
||||
summary = "Hold a key to zoom in; the mouse wheel zooms further while you hold it."
|
||||
description = """
|
||||
@@ -11,11 +11,24 @@ Horizon's own zoom, formerly an extra in Options > Horizon Extras.
|
||||
- Settings: Options > Mods > Zoom > Settings (the key; starting zoom; smooth zoom; slower turning).
|
||||
|
||||
It hears its own key and the mouse wheel while you hold it, and zooms the camera: nothing else.
|
||||
|
||||
Now a native mod (2.0.0): built into the game by the launcher, on the Horizon you play, like any native mod
|
||||
(Settings → Game → Native mods (experimental), then Build). Your settings carry over from 1.0.0. Turn it off in
|
||||
Options > Mods: it rests at once, and the next build leaves it out. Its source is
|
||||
[horizon-mods/native/zoom](https://github.com/horizon-rust/horizon-mods/tree/main/native/zoom).
|
||||
"""
|
||||
authors = ["Horizon"]
|
||||
owners = ["ludovic111"]
|
||||
license = "MIT"
|
||||
source = "https://github.com/horizon-rust/horizon/tree/main/mods/official/zoom"
|
||||
source = "https://github.com/horizon-rust/horizon-mods/tree/main/native/zoom"
|
||||
|
||||
[[versions]]
|
||||
version = "2.0.0"
|
||||
published = "2026-10-29"
|
||||
game = "26.3"
|
||||
file = "zoom-2.0.0.hnmod"
|
||||
sha256 = "49fa2d7d635225c53304fbf1477606c0677eb23d94dac75b730f6c3ba065f127"
|
||||
changelog = "Native: built into the game by the launcher on Horizon's native hook points; settings carry over; turn it off in Options > Mods."
|
||||
|
||||
[[versions]]
|
||||
version = "1.0.0"
|
||||
|
||||
Generated
+7
@@ -0,0 +1,7 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "horizon-mod-fps-overlay"
|
||||
version = "2.0.0"
|
||||
@@ -0,0 +1,11 @@
|
||||
[package]
|
||||
name = "horizon-mod-fps-overlay"
|
||||
version = "2.0.0"
|
||||
edition = "2024"
|
||||
license = "MIT"
|
||||
publish = false
|
||||
description = "Horizon's official FPS counter, a native mod: frames per second in a corner of the screen."
|
||||
|
||||
# The mod's logic and its tests: the same file the bundle adds to the game.
|
||||
[lib]
|
||||
path = "mixins/fps_overlay/runtime.rs"
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Ludovic Marie and the Horizon contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,43 @@
|
||||
# FPS Counter (Horizon native mod)
|
||||
|
||||
Horizon's official FPS counter: frames per second in a box in a corner of the screen, and, if you like, the slowest frame and the 1% low of the last 5 seconds. Its settings page (Options > Mods > FPS Counter > Settings) picks the corner and the lows.
|
||||
|
||||
It is a [horizon-mixins](https://github.com/horizon-rust/horizon-mixins) bundle (`mixins/fps_overlay/`) that the Horizon
|
||||
launcher builds into the game. It replaces the WebAssembly FPS Counter 1.x and keeps its settings: the same files (`horizon-mods/.horizon-mod-data/fps_overlay/corner`, `lows`).
|
||||
|
||||
## Install
|
||||
|
||||
In the Horizon launcher: Mods → FPS Counter → Install, then Settings → Game → Native mods (experimental) → Build. The
|
||||
launcher downloads its own Rust and builds the game with your native mods; see Horizon's
|
||||
[native mods](https://github.com/horizon-rust/horizon/blob/main/docs/native-mods.md).
|
||||
|
||||
## Made on the hook points
|
||||
|
||||
The bundle hooks only Horizon's native hook points (`crates/horizon-client/src/game/native_hooks.rs`), which keep
|
||||
their names and signatures across releases. `compatibility.json` names the version it needs (`"hooks": 1`), so the
|
||||
launcher builds FPS Counter on any Horizon with that version or a later one, the player's own game by default, together with any other
|
||||
mod. `horizon_revision` is only the commit it was last tested on.
|
||||
|
||||
| File | What it is |
|
||||
|---|---|
|
||||
| `mixins/fps_overlay/horizon-mixin.toml` | The bundle: one module and the HEAD hooks |
|
||||
| `mixins/fps_overlay/runtime.rs` | The mod's logic and state, added to the game as `game::official_fps_overlay`; also this crate's library, for its tests |
|
||||
| `mixins/fps_overlay/*.rs` | One small helper per hook |
|
||||
| `../tools/native.py` | Shared by the native mods: apply on Horizon (from GitHub, by commit), check, build, package the `.hnmod`, run the proof |
|
||||
| `proof.py` (with `../tools/game.py`) | The proof: the built game driven with `horizon-ctl --offscreen` on fresh, temporary data |
|
||||
|
||||
## Build and prove
|
||||
|
||||
```sh
|
||||
(cd native/fps-overlay && cargo test) # the logic
|
||||
python3 native/tools/native.py check fps-overlay # apply on the tested Horizon and cargo check the game
|
||||
python3 native/tools/native.py check fps-overlay --horizon latest # ... on the newest Nightly
|
||||
python3 native/tools/native.py check all # with the other native mods, as a player's set
|
||||
python3 native/tools/native.py package fps-overlay # native/target/fps_overlay-<version>.hnmod for the registry
|
||||
python3 native/tools/native.py proof fps-overlay --resources /path/to/.local/horizon-client/26.3
|
||||
```
|
||||
|
||||
The proof needs resources prepared from your own Minecraft 26.3 (`horizon-prepare`); none are in this repository.
|
||||
It seeds the WebAssembly mod's settings (bottom right, lows on) in a temporary game folder, then checks that Options > Mods shows them, and that the Corner button moves the box to the top left and writes the same file. CI runs `cargo test`, packages the `.hnmod` and checks the game on the tested commit, and every Monday on the newest Nightly.
|
||||
|
||||
License: MIT.
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"game": "26.3",
|
||||
"hooks": 1,
|
||||
"horizon_repository": "https://github.com/horizon-rust/horizon",
|
||||
"horizon_revision": "6f8c7a0e5d531d3834c4d0be4659a1db8e7e5c2a",
|
||||
"horizon_mixins_repository": "https://github.com/horizon-rust/horizon-mixins",
|
||||
"horizon_mixins_revision": "0d6b103c70633d8fd9443d8d0acd3aadaacedb06"
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
// A click on the mod's settings page: change the setting and keep it.
|
||||
fn official_fps_overlay_click(game: &mut crate::game::Game<'_>, mod_id: &str, button: usize) {
|
||||
use crate::game::official_fps_overlay as m;
|
||||
if mod_id != m::ID {
|
||||
return;
|
||||
}
|
||||
let mut settings = m::settings(|key| game.native_setting(m::ID, key));
|
||||
settings.click(button);
|
||||
m::set_settings(settings);
|
||||
for (key, value) in settings.stored() {
|
||||
game.native_set_setting(m::ID, key, &value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
[mod]
|
||||
id = "fps_overlay"
|
||||
version = "2.0.0"
|
||||
priority = 100
|
||||
|
||||
# The commit it was tested on. It uses only the native hook points (hooks 1 in
|
||||
# compatibility.json): the launcher builds it on any Horizon that has them.
|
||||
[compatibility]
|
||||
horizon_revision = "6f8c7a0e5d531d3834c4d0be4659a1db8e7e5c2a"
|
||||
|
||||
[[module]]
|
||||
id = "runtime"
|
||||
file = "crates/horizon-client/src/game.rs"
|
||||
name = "official_fps_overlay"
|
||||
source = "runtime.rs"
|
||||
|
||||
[[injection]]
|
||||
id = "list"
|
||||
kind = "HEAD"
|
||||
target = "horizon_client::game::native_hooks::Game<'_>::native_mods"
|
||||
helper = "list.rs"
|
||||
bindings = [
|
||||
{ name = "mods", ty = "&mut Vec<crate::game::native_hooks::NativeMod>", value = "&mut *mods" },
|
||||
]
|
||||
|
||||
[[injection]]
|
||||
id = "settings"
|
||||
kind = "HEAD"
|
||||
target = "horizon_client::game::native_hooks::Game<'_>::native_settings"
|
||||
helper = "settings.rs"
|
||||
bindings = [
|
||||
{ name = "game", ty = "&mut crate::game::Game<'_>", value = "&mut *self" },
|
||||
{ name = "mod_id", ty = "&str", value = "mod_id" },
|
||||
{ name = "buttons", ty = "&mut Vec<String>", value = "&mut *buttons" },
|
||||
]
|
||||
|
||||
[[injection]]
|
||||
id = "click"
|
||||
kind = "HEAD"
|
||||
target = "horizon_client::game::native_hooks::Game<'_>::native_settings_click"
|
||||
helper = "click.rs"
|
||||
bindings = [
|
||||
{ name = "game", ty = "&mut crate::game::Game<'_>", value = "&mut *self" },
|
||||
{ name = "mod_id", ty = "&str", value = "mod_id" },
|
||||
{ name = "button", ty = "usize", value = "button" },
|
||||
]
|
||||
|
||||
[[injection]]
|
||||
id = "hud"
|
||||
kind = "HEAD"
|
||||
target = "horizon_client::game::native_hooks::Game<'_>::native_hud"
|
||||
helper = "hud.rs"
|
||||
bindings = [
|
||||
{ name = "game", ty = "&mut crate::game::Game<'_>", value = "&mut *self" },
|
||||
{ name = "batch", ty = "&mut horizon_client::hud::HudBatch", value = "&mut *batch" },
|
||||
{ name = "corners", ty = "&mut horizon_client::extras_hud::Corners", value = "&mut *corners" },
|
||||
]
|
||||
@@ -0,0 +1,27 @@
|
||||
// The box in its corner, as the extra drew it; the debug screen takes the top
|
||||
// left.
|
||||
fn fps_hud(
|
||||
game: &mut crate::game::Game<'_>,
|
||||
batch: &mut horizon_client::hud::HudBatch,
|
||||
corners: &mut horizon_client::extras_hud::Corners,
|
||||
) {
|
||||
use crate::game::official_fps_overlay as m;
|
||||
// Turned off in Options > Mods: nothing until the next build leaves it out.
|
||||
if !game.native_on(m::ID) {
|
||||
return;
|
||||
}
|
||||
use horizon_client::extras::Corner;
|
||||
let settings = {
|
||||
let game = &*game;
|
||||
m::settings(|key| game.native_setting(m::ID, key))
|
||||
};
|
||||
let corner = [Corner::TopLeft, Corner::TopRight, Corner::BottomLeft, Corner::BottomRight][settings.corner];
|
||||
if !game.native_corner_shown(corner) {
|
||||
return;
|
||||
}
|
||||
let text = settings.text(game.native_frame_rate());
|
||||
let w = game.native_text_width(&text) + 4.0;
|
||||
let (x, y) = corners.place(corner, w, 12.0);
|
||||
game.native_fill(batch, [x, y, w, 12.0], m::BACKGROUND);
|
||||
game.native_text(batch, &text, [x + 2.0, y + 2.0], m::INK, true);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
// The mod's row in Options > Mods.
|
||||
fn official_fps_overlay_listed(mods: &mut Vec<crate::game::native_hooks::NativeMod>) {
|
||||
use crate::game::official_fps_overlay as m;
|
||||
mods.push(crate::game::native_hooks::NativeMod {
|
||||
id: m::ID.into(),
|
||||
title: format!("{} {}", m::NAME, m::VERSION),
|
||||
description: m::DESCRIPTION.into(),
|
||||
keys: Vec::new(),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
// Horizon's official FPS counter, formerly the WebAssembly mod and before
|
||||
// that a built-in extra: the game's frame counter (frames in the last second,
|
||||
// the slowest frame and the 1% low of the last 5 seconds) in a box in a
|
||||
// corner, drawn as the extra drew it. Its settings are the WebAssembly mod's
|
||||
// files (`horizon-mods/.horizon-mod-data/fps_overlay/corner`, `lows`), so a
|
||||
// player keeps them. This file is both the mod's Cargo library (its tests)
|
||||
// and the module the bundle adds to the game.
|
||||
|
||||
/// The mod's id (its settings folder), name and version.
|
||||
pub const ID: &str = "fps_overlay";
|
||||
pub const NAME: &str = "FPS Counter";
|
||||
pub const VERSION: &str = "2.0.0";
|
||||
pub const DESCRIPTION: &str = "Frames per second in a corner of the screen, with the slowest frame and the 1% low if you like. Horizon's own.";
|
||||
/// The extra's box: translucent black, white text with a shadow.
|
||||
pub const BACKGROUND: u32 = 0x7800_0000;
|
||||
pub const INK: u32 = 0xFF_FFFF;
|
||||
/// The corners, as the settings files name them and the page labels them.
|
||||
pub const CORNERS: [(&str, &str); 4] = [
|
||||
("top_left", "Top left"),
|
||||
("top_right", "Top right"),
|
||||
("bottom_left", "Bottom left"),
|
||||
("bottom_right", "Bottom right"),
|
||||
];
|
||||
|
||||
/// The settings, as the WebAssembly FPS Counter kept them.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Settings {
|
||||
/// Index in `CORNERS`.
|
||||
pub corner: usize,
|
||||
/// Show the slowest frame and the 1% low too.
|
||||
pub lows: bool,
|
||||
}
|
||||
|
||||
impl Settings {
|
||||
/// From the stored values; the extra's defaults (top left, no lows) for
|
||||
/// the ones missing.
|
||||
pub fn read(get: impl Fn(&str) -> Option<String>) -> Self {
|
||||
let corner = get("corner")
|
||||
.and_then(|c| CORNERS.iter().position(|k| k.0 == c.trim()))
|
||||
.unwrap_or(0);
|
||||
let lows = get("lows").is_some_and(|v| v.trim() == "true");
|
||||
Self { corner, lows }
|
||||
}
|
||||
pub fn labels(&self) -> Vec<String> {
|
||||
let on = |b: bool| if b { "ON" } else { "OFF" };
|
||||
vec![
|
||||
format!("Corner: {}", CORNERS[self.corner].1),
|
||||
format!("Minimum and 1% low: {}", on(self.lows)),
|
||||
]
|
||||
}
|
||||
pub fn click(&mut self, i: usize) {
|
||||
match i {
|
||||
0 => self.corner = (self.corner + 1) % CORNERS.len(),
|
||||
1 => self.lows = !self.lows,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
pub fn stored(&self) -> [(&'static str, String); 2] {
|
||||
let lows = if self.lows { "true" } else { "false" };
|
||||
[
|
||||
("corner", CORNERS[self.corner].0.to_owned()),
|
||||
("lows", lows.to_owned()),
|
||||
]
|
||||
}
|
||||
/// The box's text for a reading (fps, min, 1% low).
|
||||
pub fn text(&self, (fps, min, low): (u32, u32, u32)) -> String {
|
||||
if self.lows {
|
||||
format!("{fps} fps min {min} 1% {low}")
|
||||
} else {
|
||||
format!("{fps} fps")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
/// The settings, read once from the files, then kept.
|
||||
static SETTINGS: std::cell::Cell<Option<Settings>> = const { std::cell::Cell::new(None) };
|
||||
}
|
||||
|
||||
/// The settings, read once through `get` (one file per key).
|
||||
pub fn settings(get: impl Fn(&str) -> Option<String>) -> Settings {
|
||||
SETTINGS.with(|s| {
|
||||
let found = s.get().unwrap_or_else(|| Settings::read(get));
|
||||
s.set(Some(found));
|
||||
found
|
||||
})
|
||||
}
|
||||
|
||||
/// Replaces the settings (the settings page changed them).
|
||||
pub fn set_settings(settings: Settings) {
|
||||
SETTINGS.with(|s| s.set(Some(settings)));
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn stored<'a>(pairs: &'a [(&'a str, &'a str)]) -> impl Fn(&str) -> Option<String> + 'a {
|
||||
|key| pairs.iter().find(|p| p.0 == key).map(|p| p.1.to_owned())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_and_writes_the_webassembly_mods_settings() {
|
||||
let s = Settings::read(stored(&[]));
|
||||
assert_eq!(
|
||||
s,
|
||||
Settings {
|
||||
corner: 0,
|
||||
lows: false
|
||||
}
|
||||
);
|
||||
assert_eq!(s.text((60, 40, 30)), "60 fps");
|
||||
let mut s = Settings::read(stored(&[("corner", "bottom_right\n"), ("lows", "true")]));
|
||||
assert_eq!(
|
||||
s,
|
||||
Settings {
|
||||
corner: 3,
|
||||
lows: true
|
||||
}
|
||||
);
|
||||
assert_eq!(s.text((60, 40, 30)), "60 fps min 40 1% 30");
|
||||
assert_eq!(
|
||||
s.labels(),
|
||||
["Corner: Bottom right", "Minimum and 1% low: ON"]
|
||||
);
|
||||
s.click(0);
|
||||
s.click(1);
|
||||
assert_eq!(
|
||||
s.stored(),
|
||||
[("corner", "top_left".into()), ("lows", "false".into())]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
// The mod's settings page: the WebAssembly mod's buttons.
|
||||
fn official_fps_overlay_page(game: &mut crate::game::Game<'_>, mod_id: &str, buttons: &mut Vec<String>) {
|
||||
use crate::game::official_fps_overlay as m;
|
||||
if mod_id != m::ID {
|
||||
return;
|
||||
}
|
||||
let game = &*game;
|
||||
buttons.extend(m::settings(|key| game.native_setting(m::ID, key)).labels());
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
"""FPS Counter in a running modded game: the WebAssembly mod's settings files are read and written as they
|
||||
were, and the frame counter shows in its corner and moves with the Corner setting."""
|
||||
import json
|
||||
import time
|
||||
from game import Game, difference
|
||||
|
||||
DATA = "horizon-mods/.horizon-mod-data/fps_overlay"
|
||||
# A player's settings from the WebAssembly mod.
|
||||
FILES = {f"{DATA}/corner": "bottom_right", f"{DATA}/lows": "true"}
|
||||
TOP_LEFT, OTHER = (0.0, 0.0, 0.25, 0.1), (0.75, 0.9, 1.0, 1.0)
|
||||
|
||||
|
||||
def run(ctl, resources, out):
|
||||
with Game(ctl, resources, out, FILES) as g:
|
||||
g.run("options", "mods")
|
||||
assert "FPS Counter 2.0.0: ON" in g.labels(), g.labels()
|
||||
g.run("click", g.ids()["Settings"])
|
||||
page = g.labels()
|
||||
assert page[:2] == ["Corner: Bottom right", "Minimum and 1% low: ON"], page
|
||||
g.shot("settings-page")
|
||||
g.play()
|
||||
g.run("command", "tp @s ~ ~ ~ 0 10")
|
||||
time.sleep(2)
|
||||
first = g.shot("1-bottom-right")
|
||||
g.run("options", "mods")
|
||||
g.run("click", g.ids()["Settings"])
|
||||
g.run("click", g.ids()["Corner: Bottom right"])
|
||||
moved = (g.home / DATA / "corner").read_text()
|
||||
g.play()
|
||||
time.sleep(2)
|
||||
second = g.shot("2-top-left")
|
||||
results = {
|
||||
"corner_file_after_click": moved,
|
||||
"diff_top_left": round(difference(first, second, TOP_LEFT), 1),
|
||||
"diff_old_corner": round(difference(first, second, OTHER), 1),
|
||||
}
|
||||
assert moved == "top_left", results
|
||||
assert results["diff_top_left"] > 4 and results["diff_old_corner"] > 4, results
|
||||
print(json.dumps({"proof": "fps_overlay", "ok": True, **results}, indent=2))
|
||||
Generated
+7
@@ -0,0 +1,7 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "horizon-mod-keystrokes"
|
||||
version = "2.0.0"
|
||||
@@ -0,0 +1,11 @@
|
||||
[package]
|
||||
name = "horizon-mod-keystrokes"
|
||||
version = "2.0.0"
|
||||
edition = "2024"
|
||||
license = "MIT"
|
||||
publish = false
|
||||
description = "Horizon's official Keystrokes, a native mod: movement keys, mouse buttons and space bar in a corner, lit while held."
|
||||
|
||||
# The mod's logic and its tests: the same file the bundle adds to the game.
|
||||
[lib]
|
||||
path = "mixins/keystrokes/runtime.rs"
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Ludovic Marie and the Horizon contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,43 @@
|
||||
# Keystrokes (Horizon native mod)
|
||||
|
||||
Horizon's official Keystrokes: your movement keys, mouse buttons and space bar in a corner, lit while held, with clicks per second. Its settings page (Options > Mods > Keystrokes > Settings) picks the corner and the clicks per second.
|
||||
|
||||
It is a [horizon-mixins](https://github.com/horizon-rust/horizon-mixins) bundle (`mixins/keystrokes/`) that the Horizon
|
||||
launcher builds into the game. It replaces the WebAssembly Keystrokes 1.x and keeps its settings: the same files (`horizon-mods/.horizon-mod-data/keystrokes/corner`, `cps`).
|
||||
|
||||
## Install
|
||||
|
||||
In the Horizon launcher: Mods → Keystrokes → Install, then Settings → Game → Native mods (experimental) → Build. The
|
||||
launcher downloads its own Rust and builds the game with your native mods; see Horizon's
|
||||
[native mods](https://github.com/horizon-rust/horizon/blob/main/docs/native-mods.md).
|
||||
|
||||
## Made on the hook points
|
||||
|
||||
The bundle hooks only Horizon's native hook points (`crates/horizon-client/src/game/native_hooks.rs`), which keep
|
||||
their names and signatures across releases. `compatibility.json` names the version it needs (`"hooks": 1`), so the
|
||||
launcher builds Keystrokes on any Horizon with that version or a later one, the player's own game by default, together with any other
|
||||
mod. `horizon_revision` is only the commit it was last tested on.
|
||||
|
||||
| File | What it is |
|
||||
|---|---|
|
||||
| `mixins/keystrokes/horizon-mixin.toml` | The bundle: one module and the HEAD hooks |
|
||||
| `mixins/keystrokes/runtime.rs` | The mod's logic and state, added to the game as `game::official_keystrokes`; also this crate's library, for its tests |
|
||||
| `mixins/keystrokes/*.rs` | One small helper per hook |
|
||||
| `../tools/native.py` | Shared by the native mods: apply on Horizon (from GitHub, by commit), check, build, package the `.hnmod`, run the proof |
|
||||
| `proof.py` (with `../tools/game.py`) | The proof: the built game driven with `horizon-ctl --offscreen` on fresh, temporary data |
|
||||
|
||||
## Build and prove
|
||||
|
||||
```sh
|
||||
(cd native/keystrokes && cargo test) # the logic
|
||||
python3 native/tools/native.py check keystrokes # apply on the tested Horizon and cargo check the game
|
||||
python3 native/tools/native.py check keystrokes --horizon latest # ... on the newest Nightly
|
||||
python3 native/tools/native.py check all # with the other native mods, as a player's set
|
||||
python3 native/tools/native.py package keystrokes # native/target/keystrokes-<version>.hnmod for the registry
|
||||
python3 native/tools/native.py proof keystrokes --resources /path/to/.local/horizon-client/26.3
|
||||
```
|
||||
|
||||
The proof needs resources prepared from your own Minecraft 26.3 (`horizon-prepare`); none are in this repository.
|
||||
It seeds the WebAssembly mod's settings (bottom right, no clicks per second) in a temporary game folder, then checks that Options > Mods shows them, and that the Corner button moves the keys to the top left and writes the same file. CI runs `cargo test`, packages the `.hnmod` and checks the game on the tested commit, and every Monday on the newest Nightly.
|
||||
|
||||
License: MIT.
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"game": "26.3",
|
||||
"hooks": 1,
|
||||
"horizon_repository": "https://github.com/horizon-rust/horizon",
|
||||
"horizon_revision": "6f8c7a0e5d531d3834c4d0be4659a1db8e7e5c2a",
|
||||
"horizon_mixins_repository": "https://github.com/horizon-rust/horizon-mixins",
|
||||
"horizon_mixins_revision": "0d6b103c70633d8fd9443d8d0acd3aadaacedb06"
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
// A click on the mod's settings page: change the setting and keep it.
|
||||
fn official_keystrokes_click(game: &mut crate::game::Game<'_>, mod_id: &str, button: usize) {
|
||||
use crate::game::official_keystrokes as m;
|
||||
if mod_id != m::ID {
|
||||
return;
|
||||
}
|
||||
let mut settings = m::settings(|key| game.native_setting(m::ID, key));
|
||||
settings.click(button);
|
||||
m::set_settings(settings);
|
||||
for (key, value) in settings.stored() {
|
||||
game.native_set_setting(m::ID, key, &value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
[mod]
|
||||
id = "keystrokes"
|
||||
version = "2.0.0"
|
||||
priority = 100
|
||||
|
||||
# The commit it was tested on. It uses only the native hook points (hooks 1 in
|
||||
# compatibility.json): the launcher builds it on any Horizon that has them.
|
||||
[compatibility]
|
||||
horizon_revision = "6f8c7a0e5d531d3834c4d0be4659a1db8e7e5c2a"
|
||||
|
||||
[[module]]
|
||||
id = "runtime"
|
||||
file = "crates/horizon-client/src/game.rs"
|
||||
name = "official_keystrokes"
|
||||
source = "runtime.rs"
|
||||
|
||||
[[injection]]
|
||||
id = "list"
|
||||
kind = "HEAD"
|
||||
target = "horizon_client::game::native_hooks::Game<'_>::native_mods"
|
||||
helper = "list.rs"
|
||||
bindings = [
|
||||
{ name = "mods", ty = "&mut Vec<crate::game::native_hooks::NativeMod>", value = "&mut *mods" },
|
||||
]
|
||||
|
||||
[[injection]]
|
||||
id = "settings"
|
||||
kind = "HEAD"
|
||||
target = "horizon_client::game::native_hooks::Game<'_>::native_settings"
|
||||
helper = "settings.rs"
|
||||
bindings = [
|
||||
{ name = "game", ty = "&mut crate::game::Game<'_>", value = "&mut *self" },
|
||||
{ name = "mod_id", ty = "&str", value = "mod_id" },
|
||||
{ name = "buttons", ty = "&mut Vec<String>", value = "&mut *buttons" },
|
||||
]
|
||||
|
||||
[[injection]]
|
||||
id = "click"
|
||||
kind = "HEAD"
|
||||
target = "horizon_client::game::native_hooks::Game<'_>::native_settings_click"
|
||||
helper = "click.rs"
|
||||
bindings = [
|
||||
{ name = "game", ty = "&mut crate::game::Game<'_>", value = "&mut *self" },
|
||||
{ name = "mod_id", ty = "&str", value = "mod_id" },
|
||||
{ name = "button", ty = "usize", value = "button" },
|
||||
]
|
||||
|
||||
[[injection]]
|
||||
id = "hud"
|
||||
kind = "HEAD"
|
||||
target = "horizon_client::game::native_hooks::Game<'_>::native_hud"
|
||||
helper = "hud.rs"
|
||||
bindings = [
|
||||
{ name = "game", ty = "&mut crate::game::Game<'_>", value = "&mut *self" },
|
||||
{ name = "batch", ty = "&mut horizon_client::hud::HudBatch", value = "&mut *batch" },
|
||||
{ name = "corners", ty = "&mut horizon_client::extras_hud::Corners", value = "&mut *corners" },
|
||||
]
|
||||
@@ -0,0 +1,43 @@
|
||||
// The keys in their corner, as the extra drew them; the debug screen takes the
|
||||
// top left.
|
||||
fn keystrokes_hud(
|
||||
game: &mut crate::game::Game<'_>,
|
||||
batch: &mut horizon_client::hud::HudBatch,
|
||||
corners: &mut horizon_client::extras_hud::Corners,
|
||||
) {
|
||||
use crate::game::official_keystrokes as m;
|
||||
// Turned off in Options > Mods: nothing until the next build leaves it out.
|
||||
if !game.native_on(m::ID) {
|
||||
return;
|
||||
}
|
||||
use horizon_client::extras::Corner;
|
||||
let settings = {
|
||||
let game = &*game;
|
||||
m::settings(|key| game.native_setting(m::ID, key))
|
||||
};
|
||||
let corner = [Corner::TopLeft, Corner::TopRight, Corner::BottomLeft, Corner::BottomRight][settings.corner];
|
||||
if !game.native_corner_shown(corner) {
|
||||
return;
|
||||
}
|
||||
let mut down = |name: &str| game.native_mapping(name).unwrap_or_default();
|
||||
let view = m::View {
|
||||
moves: m::MOVES.map(&mut down),
|
||||
jump: down("key.jump").0,
|
||||
attack: down("key.attack").0,
|
||||
use_: down("key.use").0,
|
||||
cps: None,
|
||||
};
|
||||
let view = m::View {
|
||||
cps: settings.cps.then(|| game.native_clicks()),
|
||||
..view
|
||||
};
|
||||
let (w, h) = m::SIZE;
|
||||
let (x, y) = corners.place(corner, w, h);
|
||||
let game = &*game;
|
||||
for shape in m::shapes(&view, &|text| game.native_text_width(text)) {
|
||||
match shape {
|
||||
m::Shape::Fill([dx, dy, w, h], argb) => game.native_fill(batch, [x + dx, y + dy, w, h], argb),
|
||||
m::Shape::Text(text, [dx, dy], rgb, shadow) => game.native_text(batch, &text, [x + dx, y + dy], rgb, shadow),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
// The mod's row in Options > Mods.
|
||||
fn official_keystrokes_listed(mods: &mut Vec<crate::game::native_hooks::NativeMod>) {
|
||||
use crate::game::official_keystrokes as m;
|
||||
mods.push(crate::game::native_hooks::NativeMod {
|
||||
id: m::ID.into(),
|
||||
title: format!("{} {}", m::NAME, m::VERSION),
|
||||
description: m::DESCRIPTION.into(),
|
||||
keys: Vec::new(),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
// Horizon's official Keystrokes, formerly the WebAssembly mod and before that
|
||||
// a built-in extra: the movement keys, the mouse buttons and the space bar in
|
||||
// a corner, lit while held, with clicks per second, drawn as the extra drew
|
||||
// them. Its settings are the WebAssembly mod's files
|
||||
// (`horizon-mods/.horizon-mod-data/keystrokes/corner`, `cps`), so a player
|
||||
// keeps them. This file is both the mod's Cargo library (its tests) and the
|
||||
// module the bundle adds to the game.
|
||||
|
||||
/// The mod's id (its settings folder), name and version.
|
||||
pub const ID: &str = "keystrokes";
|
||||
pub const NAME: &str = "Keystrokes";
|
||||
pub const VERSION: &str = "2.0.0";
|
||||
pub const DESCRIPTION: &str = "Your movement keys, mouse buttons and space bar in a corner, lit while held, with clicks per second. Horizon's own.";
|
||||
/// The extra's colours: a key up is translucent black with white text, a key
|
||||
/// held is light with dark text.
|
||||
pub const BACKGROUND: u32 = 0x7800_0000;
|
||||
pub const HELD: u32 = 0xB0FF_FFFF;
|
||||
pub const INK_HELD: u32 = 0x14_1414;
|
||||
pub const INK: u32 = 0xFF_FFFF;
|
||||
/// A key's side and the gap between keys, in GUI pixels.
|
||||
const KEY: f32 = 22.0;
|
||||
const GAP: f32 = 2.0;
|
||||
/// The mappings of the top two rows, in the order they are laid out.
|
||||
pub const MOVES: [&str; 4] = ["key.forward", "key.left", "key.back", "key.right"];
|
||||
/// The corners, as the settings files name them and the page labels them.
|
||||
pub const CORNERS: [(&str, &str); 4] = [
|
||||
("top_left", "Top left"),
|
||||
("top_right", "Top right"),
|
||||
("bottom_left", "Bottom left"),
|
||||
("bottom_right", "Bottom right"),
|
||||
];
|
||||
|
||||
/// The settings, as the WebAssembly Keystrokes kept them.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Settings {
|
||||
/// Index in `CORNERS`.
|
||||
pub corner: usize,
|
||||
/// Clicks per second under the mouse buttons.
|
||||
pub cps: bool,
|
||||
}
|
||||
|
||||
impl Settings {
|
||||
/// From the stored values; the extra's defaults (top left, clicks per
|
||||
/// second shown) for the ones missing.
|
||||
pub fn read(get: impl Fn(&str) -> Option<String>) -> Self {
|
||||
let corner = get("corner")
|
||||
.and_then(|c| CORNERS.iter().position(|k| k.0 == c.trim()))
|
||||
.unwrap_or(0);
|
||||
let cps = get("cps").is_none_or(|v| v.trim() != "false");
|
||||
Self { corner, cps }
|
||||
}
|
||||
pub fn labels(&self) -> Vec<String> {
|
||||
let on = |b: bool| if b { "ON" } else { "OFF" };
|
||||
vec![
|
||||
format!("Corner: {}", CORNERS[self.corner].1),
|
||||
format!("Clicks per second: {}", on(self.cps)),
|
||||
]
|
||||
}
|
||||
pub fn click(&mut self, i: usize) {
|
||||
match i {
|
||||
0 => self.corner = (self.corner + 1) % CORNERS.len(),
|
||||
1 => self.cps = !self.cps,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
pub fn stored(&self) -> [(&'static str, String); 2] {
|
||||
let cps = if self.cps { "true" } else { "false" };
|
||||
[
|
||||
("corner", CORNERS[self.corner].0.to_owned()),
|
||||
("cps", cps.to_owned()),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
/// What the box shows: each movement key (held, label), the space bar, the
|
||||
/// mouse buttons, and the clicks per second when shown.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct View {
|
||||
pub moves: [(bool, String); 4],
|
||||
pub jump: bool,
|
||||
pub attack: bool,
|
||||
pub use_: bool,
|
||||
pub cps: Option<(u32, u32)>,
|
||||
}
|
||||
|
||||
/// One thing to draw, relative to the box's top left corner.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum Shape {
|
||||
/// x, y, w, h and argb.
|
||||
Fill([f32; 4], u32),
|
||||
/// The text, its x and y, rgb, and a shadow.
|
||||
Text(String, [f32; 2], u32, bool),
|
||||
}
|
||||
|
||||
/// The box's size.
|
||||
pub const SIZE: (f32, f32) = (3.0 * KEY + 2.0 * GAP, 3.0 * KEY + 3.0 * GAP + 10.0);
|
||||
|
||||
/// A key's box with its lines centred; `width` measures text.
|
||||
fn key_box(
|
||||
out: &mut Vec<Shape>,
|
||||
[x, y, w, h]: [f32; 4],
|
||||
held: bool,
|
||||
lines: &[&str],
|
||||
width: &dyn Fn(&str) -> f32,
|
||||
) {
|
||||
let (fill, ink) = if held {
|
||||
(HELD, INK_HELD)
|
||||
} else {
|
||||
(BACKGROUND, INK)
|
||||
};
|
||||
out.push(Shape::Fill([x, y, w, h], fill));
|
||||
let total = lines.len() as f32 * 9.0 - 1.0;
|
||||
for (i, line) in lines.iter().enumerate() {
|
||||
let at = [
|
||||
(x + (w - width(line)) / 2.0).round(),
|
||||
(y + (h - total) / 2.0).round() + i as f32 * 9.0,
|
||||
];
|
||||
out.push(Shape::Text((*line).to_owned(), at, ink, !held));
|
||||
}
|
||||
}
|
||||
|
||||
/// The box, as the extra laid it out: W, then A S D, then the mouse buttons
|
||||
/// with their clicks per second, then the space bar.
|
||||
pub fn shapes(v: &View, width: &dyn Fn(&str) -> f32) -> Vec<Shape> {
|
||||
let (w, _) = SIZE;
|
||||
let step = KEY + GAP;
|
||||
let mut out = Vec::with_capacity(24);
|
||||
let [forward, left, back, right] = &v.moves;
|
||||
key_box(
|
||||
&mut out,
|
||||
[step, 0.0, KEY, KEY],
|
||||
forward.0,
|
||||
&[&forward.1],
|
||||
width,
|
||||
);
|
||||
for (i, key) in [left, back, right].into_iter().enumerate() {
|
||||
key_box(
|
||||
&mut out,
|
||||
[i as f32 * step, step, KEY, KEY],
|
||||
key.0,
|
||||
&[&key.1],
|
||||
width,
|
||||
);
|
||||
}
|
||||
let mw = (w - GAP) / 2.0;
|
||||
let my = 2.0 * step;
|
||||
let buttons = [
|
||||
(v.attack, "LMB", v.cps.map(|c| c.0)),
|
||||
(v.use_, "RMB", v.cps.map(|c| c.1)),
|
||||
];
|
||||
for (i, (held, label, cps)) in buttons.into_iter().enumerate() {
|
||||
let rate = cps.map(|n| format!("{n} CPS"));
|
||||
let lines: Vec<&str> = std::iter::once(label).chain(rate.as_deref()).collect();
|
||||
key_box(
|
||||
&mut out,
|
||||
[i as f32 * (mw + GAP), my, mw, KEY],
|
||||
held,
|
||||
&lines,
|
||||
width,
|
||||
);
|
||||
}
|
||||
let sy = my + step;
|
||||
key_box(&mut out, [0.0, sy, w, 10.0], v.jump, &[], width);
|
||||
let bar = 0xFF00_0000 | if v.jump { INK_HELD } else { INK };
|
||||
out.push(Shape::Fill([w / 2.0 - 12.0, sy + 4.0, 24.0, 2.0], bar));
|
||||
out
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
/// The settings, read once from the files, then kept.
|
||||
static SETTINGS: std::cell::Cell<Option<Settings>> = const { std::cell::Cell::new(None) };
|
||||
}
|
||||
|
||||
/// The settings, read once through `get` (one file per key).
|
||||
pub fn settings(get: impl Fn(&str) -> Option<String>) -> Settings {
|
||||
SETTINGS.with(|s| {
|
||||
let found = s.get().unwrap_or_else(|| Settings::read(get));
|
||||
s.set(Some(found));
|
||||
found
|
||||
})
|
||||
}
|
||||
|
||||
/// Replaces the settings (the settings page changed them).
|
||||
pub fn set_settings(settings: Settings) {
|
||||
SETTINGS.with(|s| s.set(Some(settings)));
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn reads_the_webassembly_mods_settings() {
|
||||
let none = |_: &str| None;
|
||||
assert_eq!(
|
||||
Settings::read(none),
|
||||
Settings {
|
||||
corner: 0,
|
||||
cps: true
|
||||
}
|
||||
);
|
||||
let get = |k: &str| (k == "cps").then(|| "false".to_owned());
|
||||
let mut s = Settings::read(get);
|
||||
assert!(!s.cps);
|
||||
s.click(0);
|
||||
assert_eq!(s.labels(), ["Corner: Top right", "Clicks per second: OFF"]);
|
||||
assert_eq!(s.stored()[0], ("corner", "top_right".to_owned()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lays_the_keys_out_as_the_extra_did() {
|
||||
let mut v = View {
|
||||
moves: ["W", "A", "S", "D"].map(|k| (false, k.to_owned())),
|
||||
cps: Some((3, 0)),
|
||||
..View::default()
|
||||
};
|
||||
v.moves[0].0 = true;
|
||||
let width = |t: &str| t.len() as f32 * 6.0;
|
||||
let out = shapes(&v, &width);
|
||||
// W lit: light fill, dark text with no shadow, centred in its key.
|
||||
assert_eq!(out[0], Shape::Fill([24.0, 0.0, 22.0, 22.0], HELD));
|
||||
assert_eq!(
|
||||
out[1],
|
||||
Shape::Text("W".into(), [32.0, 7.0], INK_HELD, false)
|
||||
);
|
||||
assert!(out.contains(&Shape::Text("3 CPS".into(), [2.0, 60.0], INK, true)));
|
||||
assert_eq!(SIZE, (70.0, 82.0));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
// The mod's settings page: the WebAssembly mod's buttons.
|
||||
fn official_keystrokes_page(game: &mut crate::game::Game<'_>, mod_id: &str, buttons: &mut Vec<String>) {
|
||||
use crate::game::official_keystrokes as m;
|
||||
if mod_id != m::ID {
|
||||
return;
|
||||
}
|
||||
let game = &*game;
|
||||
buttons.extend(m::settings(|key| game.native_setting(m::ID, key)).labels());
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
"""Keystrokes in a running modded game: the WebAssembly mod's settings files are read and written as they
|
||||
were, and the key box shows in its corner and moves with the Corner setting."""
|
||||
import json
|
||||
import time
|
||||
from game import Game, difference
|
||||
|
||||
DATA = "horizon-mods/.horizon-mod-data/keystrokes"
|
||||
# A player's settings from the WebAssembly mod.
|
||||
FILES = {f"{DATA}/corner": "bottom_right", f"{DATA}/cps": "false"}
|
||||
TOP_LEFT, OTHER = (0.0, 0.0, 0.25, 0.3), (0.8, 0.6, 1.0, 1.0)
|
||||
|
||||
|
||||
def run(ctl, resources, out):
|
||||
with Game(ctl, resources, out, FILES) as g:
|
||||
g.run("options", "mods")
|
||||
assert "Keystrokes 2.0.0: ON" in g.labels(), g.labels()
|
||||
g.run("click", g.ids()["Settings"])
|
||||
page = g.labels()
|
||||
assert page[:2] == ["Corner: Bottom right", "Clicks per second: OFF"], page
|
||||
g.shot("settings-page")
|
||||
g.play()
|
||||
g.run("command", "tp @s ~ ~ ~ 0 10")
|
||||
time.sleep(2)
|
||||
first = g.shot("1-bottom-right")
|
||||
g.run("options", "mods")
|
||||
g.run("click", g.ids()["Settings"])
|
||||
g.run("click", g.ids()["Corner: Bottom right"])
|
||||
moved = (g.home / DATA / "corner").read_text()
|
||||
g.play()
|
||||
time.sleep(2)
|
||||
second = g.shot("2-top-left")
|
||||
results = {
|
||||
"corner_file_after_click": moved,
|
||||
"diff_top_left": round(difference(first, second, TOP_LEFT), 1),
|
||||
"diff_old_corner": round(difference(first, second, OTHER), 1),
|
||||
}
|
||||
assert moved == "top_left", results
|
||||
assert results["diff_top_left"] > 4 and results["diff_old_corner"] > 4, results
|
||||
print(json.dumps({"proof": "keystrokes", "ok": True, **results}, indent=2))
|
||||
Generated
+7
@@ -0,0 +1,7 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "horizon-mod-minimap"
|
||||
version = "2.0.0"
|
||||
@@ -0,0 +1,11 @@
|
||||
[package]
|
||||
name = "horizon-mod-minimap"
|
||||
version = "2.0.0"
|
||||
edition = "2024"
|
||||
license = "MIT"
|
||||
publish = false
|
||||
description = "Horizon's official Minimap, a native mod: the terrain around you in a corner, in map colours."
|
||||
|
||||
# The mod's logic and its tests: the same file the bundle adds to the game.
|
||||
[lib]
|
||||
path = "mixins/minimap/runtime.rs"
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Ludovic Marie and the Horizon contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,43 @@
|
||||
# Minimap (Horizon native mod)
|
||||
|
||||
Horizon's official Minimap: a map of the terrain around you in a corner, in map colours, square or round, north up or turning with you, drawn by Horizon's own minimap. Its settings page (Options > Mods > Minimap > Settings) picks the corner, size, shape, zoom and turning.
|
||||
|
||||
It is a [horizon-mixins](https://github.com/horizon-rust/horizon-mixins) bundle (`mixins/minimap/`) that the Horizon
|
||||
launcher builds into the game. It replaces the WebAssembly Minimap 1.x and keeps its settings: the same files (`horizon-mods/.horizon-mod-data/minimap/corner`, `size`, `shape`, `zoom`, `rotate`).
|
||||
|
||||
## Install
|
||||
|
||||
In the Horizon launcher: Mods → Minimap → Install, then Settings → Game → Native mods (experimental) → Build. The
|
||||
launcher downloads its own Rust and builds the game with your native mods; see Horizon's
|
||||
[native mods](https://github.com/horizon-rust/horizon/blob/main/docs/native-mods.md).
|
||||
|
||||
## Made on the hook points
|
||||
|
||||
The bundle hooks only Horizon's native hook points (`crates/horizon-client/src/game/native_hooks.rs`), which keep
|
||||
their names and signatures across releases. `compatibility.json` names the version it needs (`"hooks": 1`), so the
|
||||
launcher builds Minimap on any Horizon with that version or a later one, the player's own game by default, together with any other
|
||||
mod. `horizon_revision` is only the commit it was last tested on.
|
||||
|
||||
| File | What it is |
|
||||
|---|---|
|
||||
| `mixins/minimap/horizon-mixin.toml` | The bundle: one module and the HEAD hooks |
|
||||
| `mixins/minimap/runtime.rs` | The mod's logic and state, added to the game as `game::official_minimap`; also this crate's library, for its tests |
|
||||
| `mixins/minimap/*.rs` | One small helper per hook |
|
||||
| `../tools/native.py` | Shared by the native mods: apply on Horizon (from GitHub, by commit), check, build, package the `.hnmod`, run the proof |
|
||||
| `proof.py` (with `../tools/game.py`) | The proof: the built game driven with `horizon-ctl --offscreen` on fresh, temporary data |
|
||||
|
||||
## Build and prove
|
||||
|
||||
```sh
|
||||
(cd native/minimap && cargo test) # the logic
|
||||
python3 native/tools/native.py check minimap # apply on the tested Horizon and cargo check the game
|
||||
python3 native/tools/native.py check minimap --horizon latest # ... on the newest Nightly
|
||||
python3 native/tools/native.py check all # with the other native mods, as a player's set
|
||||
python3 native/tools/native.py package minimap # native/target/minimap-<version>.hnmod for the registry
|
||||
python3 native/tools/native.py proof minimap --resources /path/to/.local/horizon-client/26.3
|
||||
```
|
||||
|
||||
The proof needs resources prepared from your own Minecraft 26.3 (`horizon-prepare`); none are in this repository.
|
||||
It seeds the WebAssembly mod's settings (bottom right, round, 2 blocks per pixel) in a temporary game folder, then checks that Options > Mods shows them, and that the Corner button moves the map to the top left and writes the same file. CI runs `cargo test`, packages the `.hnmod` and checks the game on the tested commit, and every Monday on the newest Nightly.
|
||||
|
||||
License: MIT.
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"game": "26.3",
|
||||
"hooks": 1,
|
||||
"horizon_repository": "https://github.com/horizon-rust/horizon",
|
||||
"horizon_revision": "6f8c7a0e5d531d3834c4d0be4659a1db8e7e5c2a",
|
||||
"horizon_mixins_repository": "https://github.com/horizon-rust/horizon-mixins",
|
||||
"horizon_mixins_revision": "0d6b103c70633d8fd9443d8d0acd3aadaacedb06"
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
// A click on the mod's settings page: change the setting and keep it.
|
||||
fn official_minimap_click(game: &mut crate::game::Game<'_>, mod_id: &str, button: usize) {
|
||||
use crate::game::official_minimap as m;
|
||||
if mod_id != m::ID {
|
||||
return;
|
||||
}
|
||||
let mut settings = m::settings(|key| game.native_setting(m::ID, key));
|
||||
settings.click(button);
|
||||
m::set_settings(settings);
|
||||
for (key, value) in settings.stored() {
|
||||
game.native_set_setting(m::ID, key, &value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
[mod]
|
||||
id = "minimap"
|
||||
version = "2.0.0"
|
||||
priority = 100
|
||||
|
||||
# The commit it was tested on. It uses only the native hook points (hooks 1 in
|
||||
# compatibility.json): the launcher builds it on any Horizon that has them.
|
||||
[compatibility]
|
||||
horizon_revision = "6f8c7a0e5d531d3834c4d0be4659a1db8e7e5c2a"
|
||||
|
||||
[[module]]
|
||||
id = "runtime"
|
||||
file = "crates/horizon-client/src/game.rs"
|
||||
name = "official_minimap"
|
||||
source = "runtime.rs"
|
||||
|
||||
[[injection]]
|
||||
id = "list"
|
||||
kind = "HEAD"
|
||||
target = "horizon_client::game::native_hooks::Game<'_>::native_mods"
|
||||
helper = "list.rs"
|
||||
bindings = [
|
||||
{ name = "mods", ty = "&mut Vec<crate::game::native_hooks::NativeMod>", value = "&mut *mods" },
|
||||
]
|
||||
|
||||
[[injection]]
|
||||
id = "settings"
|
||||
kind = "HEAD"
|
||||
target = "horizon_client::game::native_hooks::Game<'_>::native_settings"
|
||||
helper = "settings.rs"
|
||||
bindings = [
|
||||
{ name = "game", ty = "&mut crate::game::Game<'_>", value = "&mut *self" },
|
||||
{ name = "mod_id", ty = "&str", value = "mod_id" },
|
||||
{ name = "buttons", ty = "&mut Vec<String>", value = "&mut *buttons" },
|
||||
]
|
||||
|
||||
[[injection]]
|
||||
id = "click"
|
||||
kind = "HEAD"
|
||||
target = "horizon_client::game::native_hooks::Game<'_>::native_settings_click"
|
||||
helper = "click.rs"
|
||||
bindings = [
|
||||
{ name = "game", ty = "&mut crate::game::Game<'_>", value = "&mut *self" },
|
||||
{ name = "mod_id", ty = "&str", value = "mod_id" },
|
||||
{ name = "button", ty = "usize", value = "button" },
|
||||
]
|
||||
|
||||
[[injection]]
|
||||
id = "minimap"
|
||||
kind = "HEAD"
|
||||
target = "horizon_client::game::native_hooks::Game<'_>::native_minimap"
|
||||
helper = "minimap.rs"
|
||||
bindings = [
|
||||
{ name = "game", ty = "&crate::game::Game<'_>", value = "&*self" },
|
||||
{ name = "minimap", ty = "&mut Option<crate::game::native_hooks::NativeMinimap>", value = "&mut *minimap" },
|
||||
]
|
||||
@@ -0,0 +1,10 @@
|
||||
// The mod's row in Options > Mods.
|
||||
fn official_minimap_listed(mods: &mut Vec<crate::game::native_hooks::NativeMod>) {
|
||||
use crate::game::official_minimap as m;
|
||||
mods.push(crate::game::native_hooks::NativeMod {
|
||||
id: m::ID.into(),
|
||||
title: format!("{} {}", m::NAME, m::VERSION),
|
||||
description: m::DESCRIPTION.into(),
|
||||
keys: Vec::new(),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
// The minimap Horizon draws for the mod, unless another mod's came first.
|
||||
fn minimap_view(game: &crate::game::Game<'_>, minimap: &mut Option<crate::game::native_hooks::NativeMinimap>) {
|
||||
use crate::game::official_minimap as m;
|
||||
use horizon_client::extras::Corner;
|
||||
// Turned off in Options > Mods: nothing until the next build leaves it out.
|
||||
if minimap.is_some() || !game.native_on(m::ID) {
|
||||
return;
|
||||
}
|
||||
let settings = m::settings(|key| game.native_setting(m::ID, key));
|
||||
let (size, blocks_per_pixel) = settings.view();
|
||||
*minimap = Some(crate::game::native_hooks::NativeMinimap {
|
||||
corner: [Corner::TopLeft, Corner::TopRight, Corner::BottomLeft, Corner::BottomRight][settings.corner],
|
||||
size,
|
||||
blocks_per_pixel,
|
||||
rotate: settings.rotate,
|
||||
round: settings.round,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
// Horizon's official Minimap, formerly the WebAssembly mod and before that a
|
||||
// built-in extra: a map of the terrain around the player in a corner, in map
|
||||
// colours, square or round, north up or turning with the player, drawn by
|
||||
// Horizon's own minimap. Its settings are the WebAssembly mod's files
|
||||
// (`horizon-mods/.horizon-mod-data/minimap/corner`, `size`, `shape`, `zoom`,
|
||||
// `rotate`), so a player keeps them. This file is both the mod's Cargo
|
||||
// library (its tests) and the module the bundle adds to the game.
|
||||
|
||||
/// The mod's id (its settings folder), name and version.
|
||||
pub const ID: &str = "minimap";
|
||||
pub const NAME: &str = "Minimap";
|
||||
pub const VERSION: &str = "2.0.0";
|
||||
pub const DESCRIPTION: &str = "A map of the terrain around you in a corner, in map colours, square or round, north up or turning with you. Horizon's own.";
|
||||
/// The corners, as the settings files name them and the page labels them.
|
||||
pub const CORNERS: [(&str, &str); 4] = [
|
||||
("top_left", "Top left"),
|
||||
("top_right", "Top right"),
|
||||
("bottom_left", "Bottom left"),
|
||||
("bottom_right", "Bottom right"),
|
||||
];
|
||||
/// Sides in GUI pixels, and blocks per map pixel.
|
||||
pub const SIZES: [(u8, &str); 3] = [(64, "Small"), (96, "Medium"), (128, "Large")];
|
||||
pub const ZOOMS: [f32; 4] = [0.5, 1.0, 2.0, 4.0];
|
||||
|
||||
/// The settings, as the WebAssembly Minimap kept them.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Settings {
|
||||
/// Indices in `CORNERS`, `SIZES` and `ZOOMS`.
|
||||
pub corner: usize,
|
||||
pub size: usize,
|
||||
pub round: bool,
|
||||
pub zoom: usize,
|
||||
pub rotate: bool,
|
||||
}
|
||||
|
||||
impl Settings {
|
||||
/// From the stored values; the extra's defaults (top right, medium,
|
||||
/// square, 1 block per pixel, north up) for the ones missing.
|
||||
pub fn read(get: impl Fn(&str) -> Option<String>) -> Self {
|
||||
let get = |key: &str| get(key).map(|v| v.trim().to_owned());
|
||||
let find = |key: &str, values: &[String], default: usize| {
|
||||
get(key)
|
||||
.and_then(|v| values.iter().position(|s| *s == v))
|
||||
.unwrap_or(default)
|
||||
};
|
||||
let corners: Vec<String> = CORNERS.iter().map(|c| c.0.to_owned()).collect();
|
||||
let sizes: Vec<String> = SIZES.iter().map(|s| s.0.to_string()).collect();
|
||||
let zooms: Vec<String> = ZOOMS.iter().map(f32::to_string).collect();
|
||||
Self {
|
||||
corner: find("corner", &corners, 1),
|
||||
size: find("size", &sizes, 1),
|
||||
round: get("shape").as_deref() == Some("round"),
|
||||
zoom: find("zoom", &zooms, 1),
|
||||
rotate: get("rotate").as_deref() == Some("true"),
|
||||
}
|
||||
}
|
||||
pub fn labels(&self) -> Vec<String> {
|
||||
let n = ZOOMS[self.zoom];
|
||||
let blocks = if n < 1.0 {
|
||||
"½ block".to_owned()
|
||||
} else if n == 1.0 {
|
||||
"1 block".to_owned()
|
||||
} else {
|
||||
format!("{n} blocks")
|
||||
};
|
||||
vec![
|
||||
format!("Corner: {}", CORNERS[self.corner].1),
|
||||
format!("Size: {}", SIZES[self.size].1),
|
||||
format!("Shape: {}", if self.round { "Round" } else { "Square" }),
|
||||
format!("Zoom: {blocks} per pixel"),
|
||||
format!(
|
||||
"Turn with the player: {}",
|
||||
if self.rotate { "ON" } else { "OFF" }
|
||||
),
|
||||
]
|
||||
}
|
||||
pub fn click(&mut self, i: usize) {
|
||||
let next = |i: usize, n: usize| (i + 1) % n;
|
||||
match i {
|
||||
0 => self.corner = next(self.corner, CORNERS.len()),
|
||||
1 => self.size = next(self.size, SIZES.len()),
|
||||
2 => self.round = !self.round,
|
||||
3 => self.zoom = next(self.zoom, ZOOMS.len()),
|
||||
4 => self.rotate = !self.rotate,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
pub fn stored(&self) -> [(&'static str, String); 5] {
|
||||
[
|
||||
("corner", CORNERS[self.corner].0.to_owned()),
|
||||
("size", SIZES[self.size].0.to_string()),
|
||||
(
|
||||
"shape",
|
||||
if self.round { "round" } else { "square" }.to_owned(),
|
||||
),
|
||||
("zoom", ZOOMS[self.zoom].to_string()),
|
||||
(
|
||||
"rotate",
|
||||
if self.rotate { "true" } else { "false" }.to_owned(),
|
||||
),
|
||||
]
|
||||
}
|
||||
/// What Horizon's minimap draws: (side, blocks per pixel).
|
||||
pub fn view(&self) -> (f32, f32) {
|
||||
(f32::from(SIZES[self.size].0), ZOOMS[self.zoom])
|
||||
}
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
/// The settings, read once from the files, then kept.
|
||||
static SETTINGS: std::cell::Cell<Option<Settings>> = const { std::cell::Cell::new(None) };
|
||||
}
|
||||
|
||||
/// The settings, read once through `get` (one file per key).
|
||||
pub fn settings(get: impl Fn(&str) -> Option<String>) -> Settings {
|
||||
SETTINGS.with(|s| {
|
||||
let found = s.get().unwrap_or_else(|| Settings::read(get));
|
||||
s.set(Some(found));
|
||||
found
|
||||
})
|
||||
}
|
||||
|
||||
/// Replaces the settings (the settings page changed them).
|
||||
pub fn set_settings(settings: Settings) {
|
||||
SETTINGS.with(|s| s.set(Some(settings)));
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn reads_and_writes_the_webassembly_mods_settings() {
|
||||
let none = |_: &str| None;
|
||||
let s = Settings::read(none);
|
||||
assert_eq!(
|
||||
(s.corner, s.size, s.round, s.zoom, s.rotate),
|
||||
(1, 1, false, 1, false)
|
||||
);
|
||||
let files = [
|
||||
("corner", "bottom_left"),
|
||||
("size", "128\n"),
|
||||
("shape", "round"),
|
||||
("zoom", "0.5"),
|
||||
("rotate", "true"),
|
||||
];
|
||||
let get = |k: &str| files.iter().find(|f| f.0 == k).map(|f| f.1.to_owned());
|
||||
let mut s = Settings::read(get);
|
||||
assert_eq!(s.view(), (128.0, 0.5));
|
||||
assert_eq!(
|
||||
s.labels(),
|
||||
[
|
||||
"Corner: Bottom left",
|
||||
"Size: Large",
|
||||
"Shape: Round",
|
||||
"Zoom: ½ block per pixel",
|
||||
"Turn with the player: ON"
|
||||
]
|
||||
);
|
||||
s.click(3);
|
||||
s.click(3);
|
||||
assert_eq!(s.stored()[3], ("zoom", "2".to_owned()));
|
||||
let round = s.stored();
|
||||
let back = Settings::read(|k: &str| round.iter().find(|f| f.0 == k).map(|f| f.1.clone()));
|
||||
assert_eq!(back, s);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
// The mod's settings page: the WebAssembly mod's buttons.
|
||||
fn official_minimap_page(game: &mut crate::game::Game<'_>, mod_id: &str, buttons: &mut Vec<String>) {
|
||||
use crate::game::official_minimap as m;
|
||||
if mod_id != m::ID {
|
||||
return;
|
||||
}
|
||||
let game = &*game;
|
||||
buttons.extend(m::settings(|key| game.native_setting(m::ID, key)).labels());
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
"""Minimap in a running modded game: the WebAssembly mod's settings files are read and written as they
|
||||
were, and the map shows in its corner and moves with the Corner setting."""
|
||||
import json
|
||||
import time
|
||||
from game import Game, difference
|
||||
|
||||
DATA = "horizon-mods/.horizon-mod-data/minimap"
|
||||
# A player's settings from the WebAssembly mod.
|
||||
FILES = {f"{DATA}/corner": "bottom_right", f"{DATA}/shape": "round", f"{DATA}/zoom": "2"}
|
||||
TOP_LEFT, OTHER = (0.0, 0.0, 0.3, 0.45), (0.75, 0.55, 1.0, 1.0)
|
||||
|
||||
|
||||
def run(ctl, resources, out):
|
||||
with Game(ctl, resources, out, FILES) as g:
|
||||
g.run("options", "mods")
|
||||
assert "Minimap 2.0.0: ON" in g.labels(), g.labels()
|
||||
g.run("click", g.ids()["Settings"])
|
||||
page = g.labels()
|
||||
assert page[:5] == ["Corner: Bottom right", "Size: Medium", "Shape: Round", "Zoom: 2 blocks per pixel", "Turn with the player: OFF"], page
|
||||
g.shot("settings-page")
|
||||
g.play()
|
||||
g.run("command", "tp @s ~ ~ ~ 0 10")
|
||||
time.sleep(2)
|
||||
first = g.shot("1-bottom-right")
|
||||
g.run("options", "mods")
|
||||
g.run("click", g.ids()["Settings"])
|
||||
g.run("click", g.ids()["Corner: Bottom right"])
|
||||
moved = (g.home / DATA / "corner").read_text()
|
||||
g.play()
|
||||
time.sleep(2)
|
||||
second = g.shot("2-top-left")
|
||||
results = {
|
||||
"corner_file_after_click": moved,
|
||||
"diff_top_left": round(difference(first, second, TOP_LEFT), 1),
|
||||
"diff_old_corner": round(difference(first, second, OTHER), 1),
|
||||
}
|
||||
assert moved == "top_left", results
|
||||
assert results["diff_top_left"] > 4 and results["diff_old_corner"] > 4, results
|
||||
print(json.dumps({"proof": "minimap", "ok": True, **results}, indent=2))
|
||||
Generated
+7
@@ -0,0 +1,7 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "horizon-mod-recipes"
|
||||
version = "2.0.0"
|
||||
@@ -0,0 +1,11 @@
|
||||
[package]
|
||||
name = "horizon-mod-recipes"
|
||||
version = "2.0.0"
|
||||
edition = "2024"
|
||||
license = "MIT"
|
||||
publish = false
|
||||
description = "Horizon's official Recipes viewer, a native mod: every item beside your inventory, its recipes and uses."
|
||||
|
||||
# The mod's logic and its tests: the same file the bundle adds to the game.
|
||||
[lib]
|
||||
path = "mixins/recipes/runtime.rs"
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Ludovic Marie and the Horizon contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,51 @@
|
||||
# Recipes (Horizon native mod)
|
||||
|
||||
Horizon's official recipe viewer: every item beside your inventory, with a search box (`#tag` searches a tag). R over an
|
||||
item shows its recipes, one tab per station; U shows its uses. The + under a crafting recipe you know fills the
|
||||
crafting grid, as the recipe book does. In creative mode, turn on "Click gives items" (Options > Mods > Recipes >
|
||||
Settings): a click on an item in the panel gives you one, a right click a stack. Change R and U on the mod's page in
|
||||
Options > Mods.
|
||||
|
||||
It is a [horizon-mixins](https://github.com/horizon-rust/horizon-mixins) bundle (`mixins/recipes/`) that the Horizon
|
||||
launcher builds into the game. It replaces the WebAssembly Recipes 1.x and keeps its settings: the same file
|
||||
(`horizon-mods/.horizon-mod-data/recipes/give`) and the same keys (`key.recipes.recipe`, `key.recipes.uses` in
|
||||
`horizon-mods.txt`).
|
||||
|
||||
## Install
|
||||
|
||||
In the Horizon launcher: Mods → Recipes → Install, then Settings → Game → Native mods (experimental) → Build. The
|
||||
launcher downloads its own Rust and builds the game with your native mods; see Horizon's
|
||||
[native mods](https://github.com/horizon-rust/horizon/blob/main/docs/native-mods.md).
|
||||
|
||||
## Made on the hook points
|
||||
|
||||
The bundle hooks only Horizon's native hook points (`crates/horizon-client/src/game/native_hooks.rs`), which keep
|
||||
their names and signatures across releases. It needs the item panel and the recipe tables, so `compatibility.json`
|
||||
names version 2 (`"hooks": 2`): the launcher builds Recipes on any Horizon with that version or a later one, the
|
||||
player's own game by default, together with any other mod. `horizon_revision` is only the commit it was last tested
|
||||
on.
|
||||
|
||||
| File | What it is |
|
||||
|---|---|
|
||||
| `mixins/recipes/horizon-mixin.toml` | The bundle: two modules and the HEAD hooks |
|
||||
| `mixins/recipes/runtime.rs` | The viewer: search, pages per station, history, and its tests; added to the game as `game::official_recipes`, and this crate's library |
|
||||
| `mixins/recipes/glue.rs` | The viewer's edges in the game (`game::official_recipes_glue`): the game's tables and panel events in, the panel, give and place out |
|
||||
| `mixins/recipes/*.rs` | One small helper per hook |
|
||||
| `proof.py` (with `../tools/game.py`) | The proof: the built game driven with `horizon-ctl --offscreen` on fresh, temporary data |
|
||||
|
||||
## Build and prove
|
||||
|
||||
```sh
|
||||
(cd native/recipes && cargo test) # the viewer
|
||||
python3 native/tools/native.py check recipes # apply on the tested Horizon and cargo check the game
|
||||
python3 native/tools/native.py check all # with the other native mods, as a player's set
|
||||
python3 native/tools/native.py package recipes # native/target/recipes-<version>.hnmod for the registry
|
||||
python3 native/tools/native.py proof recipes --resources /path/to/.local/horizon-client/26.3
|
||||
```
|
||||
|
||||
The proof needs resources prepared from your own Minecraft 26.3 (`horizon-prepare`); none are in this repository.
|
||||
It seeds the WebAssembly mod's setting ("Click gives items" on) in a temporary game folder, checks that Options > Mods
|
||||
shows the mod, its keys and the setting, then opens the inventory: the panel shows items, a click on one in creative
|
||||
mode gives it, and with the setting turned off the same click opens its recipe page.
|
||||
|
||||
License: MIT.
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"game": "26.3",
|
||||
"hooks": 2,
|
||||
"horizon_repository": "https://github.com/horizon-rust/horizon",
|
||||
"horizon_revision": "8f91683323ac1165e7d76b3037e18f30fe647205",
|
||||
"horizon_mixins_repository": "https://github.com/horizon-rust/horizon-mixins",
|
||||
"horizon_mixins_revision": "0d6b103c70633d8fd9443d8d0acd3aadaacedb06"
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
// A click on the mod's settings page: "Click gives items" flips, kept where
|
||||
// the WebAssembly mod kept it.
|
||||
fn official_recipes_click(game: &mut crate::game::Game<'_>, mod_id: &str, button: usize) {
|
||||
use crate::game::official_recipes as m;
|
||||
if mod_id != m::ID || button != 0 {
|
||||
return;
|
||||
}
|
||||
let give = !m::gives(game.native_setting(m::ID, "give"));
|
||||
game.native_set_setting(m::ID, "give", if give { "true" } else { "false" });
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
// The player's click, search or key on the panel, when it is this mod's.
|
||||
fn official_recipes_event(
|
||||
game: &mut crate::game::Game<'_>,
|
||||
mod_id: &str,
|
||||
event: &crate::game::native_hooks::NativePanelEvent,
|
||||
) {
|
||||
use crate::game::official_recipes as m;
|
||||
if mod_id != m::ID || !game.native_on(m::ID) {
|
||||
return;
|
||||
}
|
||||
crate::game::official_recipes_glue::event(game, event);
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
// Each frame: the item panel follows the world and the mod's on/off.
|
||||
fn official_recipes_frame(game: &mut crate::game::Game<'_>) {
|
||||
crate::game::official_recipes_glue::frame(game);
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
// The viewer's edges in the game (not in the Cargo library): the game's
|
||||
// tables and events converted to the viewer's types, and what the viewer
|
||||
// asks for done through the native hook points (API 2).
|
||||
use crate::game::Game;
|
||||
use crate::game::native_hooks::{
|
||||
NativeItem, NativePanelEvent, NativeRecipe, NativeRecipeKind, NativeRecipeLayout,
|
||||
NativeRecipeStack, NativeRecipeView, NativeStack,
|
||||
};
|
||||
use crate::game::official_recipes as m;
|
||||
|
||||
fn item(i: &NativeItem) -> m::Item {
|
||||
m::Item {
|
||||
id: i.id.clone(),
|
||||
name: i.name.clone(),
|
||||
tags: i.tags.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn ingredient(s: &NativeRecipeStack) -> m::Ingredient {
|
||||
m::Ingredient {
|
||||
item: s.item,
|
||||
count: s.count,
|
||||
components: s.components.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn recipe(r: &NativeRecipe) -> m::Recipe {
|
||||
let kind = match r.kind {
|
||||
NativeRecipeKind::CraftingShaped => m::Kind::CraftingShaped,
|
||||
NativeRecipeKind::CraftingShapeless => m::Kind::CraftingShapeless,
|
||||
NativeRecipeKind::Smelting => m::Kind::Smelting,
|
||||
NativeRecipeKind::Blasting => m::Kind::Blasting,
|
||||
NativeRecipeKind::Smoking => m::Kind::Smoking,
|
||||
NativeRecipeKind::Campfire => m::Kind::Campfire,
|
||||
NativeRecipeKind::Stonecutting => m::Kind::Stonecutting,
|
||||
NativeRecipeKind::Smithing => m::Kind::Smithing,
|
||||
NativeRecipeKind::Brewing => m::Kind::Brewing,
|
||||
};
|
||||
m::Recipe {
|
||||
kind,
|
||||
width: r.width,
|
||||
height: r.height,
|
||||
slots: r
|
||||
.slots
|
||||
.iter()
|
||||
.map(|s| s.iter().map(ingredient).collect())
|
||||
.collect(),
|
||||
results: r.results.iter().map(ingredient).collect(),
|
||||
cooking_time: r.cooking_time,
|
||||
experience: r.experience,
|
||||
book_id: r.book_id,
|
||||
}
|
||||
}
|
||||
|
||||
fn native(s: &m::Stack) -> NativeStack {
|
||||
NativeStack {
|
||||
item: s.item.clone(),
|
||||
count: s.count,
|
||||
components: s.components.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn stack(s: &NativeStack) -> m::Stack {
|
||||
m::Stack {
|
||||
item: s.item.clone(),
|
||||
count: s.count,
|
||||
components: s.components.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn view(v: &m::View) -> NativeRecipeView {
|
||||
NativeRecipeView {
|
||||
layout: match v.layout {
|
||||
m::Layout::Grid => NativeRecipeLayout::Grid,
|
||||
m::Layout::Cooking => NativeRecipeLayout::Cooking,
|
||||
m::Layout::Single => NativeRecipeLayout::Single,
|
||||
m::Layout::Smithing => NativeRecipeLayout::Smithing,
|
||||
m::Layout::Brewing => NativeRecipeLayout::Brewing,
|
||||
},
|
||||
width: v.width,
|
||||
height: v.height,
|
||||
slots: v
|
||||
.slots
|
||||
.iter()
|
||||
.map(|s| s.iter().map(native).collect())
|
||||
.collect(),
|
||||
results: v.results.iter().map(native).collect(),
|
||||
note: v.note.clone(),
|
||||
action: v.action,
|
||||
}
|
||||
}
|
||||
|
||||
/// Loads the world's tables once a world is open, and drops them (and the
|
||||
/// panel) when it closes or the player turns the mod off.
|
||||
pub(super) fn frame(game: &mut Game<'_>) {
|
||||
let tables = if game.native_on(m::ID) {
|
||||
game.native_recipes()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
match (tables, m::loaded()) {
|
||||
(Some(t), false) => {
|
||||
let (viewer, out) = m::Viewer::new(
|
||||
t.items.iter().map(item).collect(),
|
||||
t.recipes.iter().map(recipe).collect(),
|
||||
);
|
||||
m::set_viewer(Some(viewer));
|
||||
apply(game, out);
|
||||
}
|
||||
(None, true) => {
|
||||
m::set_viewer(None);
|
||||
game.native_panel_recipes(m::ID, "", Vec::new(), 0, Vec::new());
|
||||
game.native_panel_items(m::ID, Vec::new());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// The player's action on the panel, as the WebAssembly mod handled it.
|
||||
pub(super) fn event(game: &mut Game<'_>, event: &NativePanelEvent) {
|
||||
let event = match event {
|
||||
NativePanelEvent::Search(text) => m::Event::Search(text.clone()),
|
||||
NativePanelEvent::Clicked { index, right } => m::Event::Clicked {
|
||||
index: *index as usize,
|
||||
right: *right,
|
||||
},
|
||||
NativePanelEvent::Key { key, stack: s } => {
|
||||
let [[recipe, _, recipe_default], [uses, _, uses_default]] = m::KEYS;
|
||||
let uses = if *key == game.native_mod_key(m::ID, recipe, recipe_default) {
|
||||
false
|
||||
} else if *key == game.native_mod_key(m::ID, uses, uses_default) {
|
||||
true
|
||||
} else {
|
||||
return;
|
||||
};
|
||||
m::Event::Key {
|
||||
uses,
|
||||
stack: stack(s),
|
||||
}
|
||||
}
|
||||
NativePanelEvent::SlotClicked {
|
||||
stack: s, right, ..
|
||||
} => m::Event::SlotClicked {
|
||||
stack: stack(s),
|
||||
right: *right,
|
||||
},
|
||||
NativePanelEvent::Tab(i) => m::Event::Tab(*i as usize),
|
||||
NativePanelEvent::Action(i) => m::Event::Action(*i as usize),
|
||||
NativePanelEvent::Back => m::Event::Back,
|
||||
_ => return,
|
||||
};
|
||||
let give = m::gives(game.native_setting(m::ID, "give"));
|
||||
let known = game.native_known_recipes();
|
||||
let outs = m::with_viewer(|v| v.handle(event.clone(), give, true, &known)).unwrap_or_default();
|
||||
for out in outs {
|
||||
if let m::Out::Give { item, count } = &out {
|
||||
// Not in creative mode: the click shows the item's page, as with the setting off.
|
||||
if game.native_give(item, *count).is_err() {
|
||||
let again = m::with_viewer(|v| v.handle(event.clone(), false, false, &known));
|
||||
for out in again.unwrap_or_default() {
|
||||
apply(game, out);
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
apply(game, out);
|
||||
}
|
||||
}
|
||||
|
||||
fn apply(game: &mut Game<'_>, out: m::Out) {
|
||||
match out {
|
||||
m::Out::Items(items) => game.native_panel_items(m::ID, items.iter().map(native).collect()),
|
||||
m::Out::Recipes {
|
||||
title,
|
||||
tabs,
|
||||
tab,
|
||||
views,
|
||||
} => game.native_panel_recipes(
|
||||
m::ID,
|
||||
&title,
|
||||
tabs.iter().map(native).collect(),
|
||||
tab as u32,
|
||||
views.iter().map(view).collect(),
|
||||
),
|
||||
m::Out::Give { item, count } => {
|
||||
let _ = game.native_give(&item, count);
|
||||
}
|
||||
m::Out::Place(book) => {
|
||||
if let Err(e) = game.native_place_recipe(book, false) {
|
||||
game.native_chat(&e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
[mod]
|
||||
id = "recipes"
|
||||
version = "2.0.0"
|
||||
priority = 100
|
||||
|
||||
# The commit it was tested on. It uses only the native hook points (hooks 2 in
|
||||
# compatibility.json): the launcher builds it on any Horizon that has them.
|
||||
[compatibility]
|
||||
horizon_revision = "8f91683323ac1165e7d76b3037e18f30fe647205"
|
||||
|
||||
[[module]]
|
||||
id = "runtime"
|
||||
file = "crates/horizon-client/src/game.rs"
|
||||
name = "official_recipes"
|
||||
source = "runtime.rs"
|
||||
|
||||
[[module]]
|
||||
id = "glue"
|
||||
file = "crates/horizon-client/src/game.rs"
|
||||
name = "official_recipes_glue"
|
||||
source = "glue.rs"
|
||||
|
||||
[[injection]]
|
||||
id = "list"
|
||||
kind = "HEAD"
|
||||
target = "horizon_client::game::native_hooks::Game<'_>::native_mods"
|
||||
helper = "list.rs"
|
||||
bindings = [
|
||||
{ name = "mods", ty = "&mut Vec<crate::game::native_hooks::NativeMod>", value = "&mut *mods" },
|
||||
]
|
||||
|
||||
[[injection]]
|
||||
id = "settings"
|
||||
kind = "HEAD"
|
||||
target = "horizon_client::game::native_hooks::Game<'_>::native_settings"
|
||||
helper = "settings.rs"
|
||||
bindings = [
|
||||
{ name = "game", ty = "&mut crate::game::Game<'_>", value = "&mut *self" },
|
||||
{ name = "mod_id", ty = "&str", value = "mod_id" },
|
||||
{ name = "buttons", ty = "&mut Vec<String>", value = "&mut *buttons" },
|
||||
]
|
||||
|
||||
[[injection]]
|
||||
id = "click"
|
||||
kind = "HEAD"
|
||||
target = "horizon_client::game::native_hooks::Game<'_>::native_settings_click"
|
||||
helper = "click.rs"
|
||||
bindings = [
|
||||
{ name = "game", ty = "&mut crate::game::Game<'_>", value = "&mut *self" },
|
||||
{ name = "mod_id", ty = "&str", value = "mod_id" },
|
||||
{ name = "button", ty = "usize", value = "button" },
|
||||
]
|
||||
|
||||
[[injection]]
|
||||
id = "frame"
|
||||
kind = "HEAD"
|
||||
target = "horizon_client::game::native_hooks::Game<'_>::native_frame"
|
||||
helper = "frame.rs"
|
||||
bindings = [
|
||||
{ name = "game", ty = "&mut crate::game::Game<'_>", value = "&mut *self" },
|
||||
]
|
||||
|
||||
[[injection]]
|
||||
id = "event"
|
||||
kind = "HEAD"
|
||||
target = "horizon_client::game::native_hooks::Game<'_>::native_panel_event"
|
||||
helper = "event.rs"
|
||||
bindings = [
|
||||
{ name = "game", ty = "&mut crate::game::Game<'_>", value = "&mut *self" },
|
||||
{ name = "mod_id", ty = "&str", value = "mod_id" },
|
||||
{ name = "event", ty = "&crate::game::native_hooks::NativePanelEvent", value = "event" },
|
||||
]
|
||||
@@ -0,0 +1,10 @@
|
||||
// The mod's row in Options > Mods, with its two keys.
|
||||
fn official_recipes_listed(mods: &mut Vec<crate::game::native_hooks::NativeMod>) {
|
||||
use crate::game::official_recipes as m;
|
||||
mods.push(crate::game::native_hooks::NativeMod {
|
||||
id: m::ID.into(),
|
||||
title: format!("{} {}", m::NAME, m::VERSION),
|
||||
description: m::DESCRIPTION.into(),
|
||||
keys: m::KEYS.iter().map(|k| k.map(String::from)).collect(),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,669 @@
|
||||
// Horizon's official recipe viewer, formerly the WebAssembly mod: every item
|
||||
// in the panel the game draws beside container screens, with a search box. R
|
||||
// over an item, the panel's or any slot's, shows its recipes, U its uses; a
|
||||
// click does the same, left for recipes and right for uses. A recipe page has
|
||||
// a tab per station, and + beside a crafting recipe the player knows fills the
|
||||
// grid as the recipe book does. In creative mode, with "Click gives items" on,
|
||||
// a click on a panel item gives one, a right click a stack. Its setting and
|
||||
// keys are the WebAssembly mod's (`horizon-mods/.horizon-mod-data/recipes/give`,
|
||||
// `key.recipes.recipe` and `key.recipes.uses` in horizon-mods.txt), so a
|
||||
// player keeps them. This file is both the mod's Cargo library (its tests) and
|
||||
// the module the bundle adds to the game; the game's types are converted at
|
||||
// the edges (frame.rs, event.rs).
|
||||
|
||||
/// The mod's id (its settings folder), name and version.
|
||||
pub const ID: &str = "recipes";
|
||||
pub const NAME: &str = "Recipes";
|
||||
pub const VERSION: &str = "2.0.0";
|
||||
pub const DESCRIPTION: &str = "Every item beside your inventory, with a search box. R over an item shows its recipes, U its uses, and + fills the crafting grid with a recipe you know. Horizon's own.";
|
||||
/// Its keys: name, label, default (the WebAssembly mod's).
|
||||
pub const KEYS: [[&str; 3]; 2] = [
|
||||
["recipe", "Show recipes", "key.keyboard.r"],
|
||||
["uses", "Show uses", "key.keyboard.u"],
|
||||
];
|
||||
/// What a recipe page holds at most, as the panel takes.
|
||||
pub const MAX_RECIPES: usize = 256;
|
||||
pub const MAX_STACKS: usize = 64;
|
||||
/// A right click's gift in creative mode (cut to one stack by the game).
|
||||
pub const STACK: u32 = 99;
|
||||
/// The stations, in the order of their tabs, with each one's icon.
|
||||
pub const STATIONS: [&str; 8] = [
|
||||
"minecraft:crafting_table",
|
||||
"minecraft:furnace",
|
||||
"minecraft:blast_furnace",
|
||||
"minecraft:smoker",
|
||||
"minecraft:campfire",
|
||||
"minecraft:stonecutter",
|
||||
"minecraft:smithing_table",
|
||||
"minecraft:brewing_stand",
|
||||
];
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Kind {
|
||||
CraftingShaped,
|
||||
CraftingShapeless,
|
||||
Smelting,
|
||||
Blasting,
|
||||
Smoking,
|
||||
Campfire,
|
||||
Stonecutting,
|
||||
Smithing,
|
||||
Brewing,
|
||||
}
|
||||
|
||||
fn station(kind: Kind) -> usize {
|
||||
match kind {
|
||||
Kind::CraftingShaped | Kind::CraftingShapeless => 0,
|
||||
Kind::Smelting => 1,
|
||||
Kind::Blasting => 2,
|
||||
Kind::Smoking => 3,
|
||||
Kind::Campfire => 4,
|
||||
Kind::Stonecutting => 5,
|
||||
Kind::Smithing => 6,
|
||||
Kind::Brewing => 7,
|
||||
}
|
||||
}
|
||||
|
||||
/// An item the game knows.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct Item {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub tags: Vec<String>,
|
||||
}
|
||||
|
||||
/// An item of the tables by its index, as recipes name them.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct Ingredient {
|
||||
pub item: u32,
|
||||
pub count: u32,
|
||||
pub components: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Recipe {
|
||||
pub kind: Kind,
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub slots: Vec<Vec<Ingredient>>,
|
||||
pub results: Vec<Ingredient>,
|
||||
pub cooking_time: u32,
|
||||
pub experience: f32,
|
||||
/// The recipe book's id, or -1.
|
||||
pub book_id: i32,
|
||||
}
|
||||
|
||||
/// An item as the panel shows it.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct Stack {
|
||||
pub item: String,
|
||||
pub count: u32,
|
||||
pub components: String,
|
||||
}
|
||||
|
||||
fn stack(item: &str) -> Stack {
|
||||
Stack {
|
||||
item: item.into(),
|
||||
count: 1,
|
||||
components: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Layout {
|
||||
Grid,
|
||||
Cooking,
|
||||
Single,
|
||||
Smithing,
|
||||
Brewing,
|
||||
}
|
||||
|
||||
/// A recipe as the panel lays it out.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct View {
|
||||
pub layout: Layout,
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub slots: Vec<Vec<Stack>>,
|
||||
pub results: Vec<Stack>,
|
||||
pub note: String,
|
||||
/// + beside it: none, or whether the book holds it.
|
||||
pub action: Option<bool>,
|
||||
}
|
||||
|
||||
/// What the player did on the panel.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum Event {
|
||||
Search(String),
|
||||
/// A panel item, by its index in the items shown.
|
||||
Clicked {
|
||||
index: usize,
|
||||
right: bool,
|
||||
},
|
||||
/// The recipes key (`uses` false) or the uses key over an item.
|
||||
Key {
|
||||
uses: bool,
|
||||
stack: Stack,
|
||||
},
|
||||
/// A slot of a recipe shown.
|
||||
SlotClicked {
|
||||
stack: Stack,
|
||||
right: bool,
|
||||
},
|
||||
Tab(usize),
|
||||
Action(usize),
|
||||
Back,
|
||||
}
|
||||
|
||||
/// What the game is to do.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum Out {
|
||||
/// Show these items in the panel.
|
||||
Items(Vec<Stack>),
|
||||
/// Show this recipe page; an empty one goes back to the items.
|
||||
Recipes {
|
||||
title: String,
|
||||
tabs: Vec<Stack>,
|
||||
tab: usize,
|
||||
views: Vec<View>,
|
||||
},
|
||||
/// Creative mode: give the player this item.
|
||||
Give { item: String, count: u32 },
|
||||
/// Place this recipe book id in the open grid.
|
||||
Place(i32),
|
||||
}
|
||||
|
||||
/// A recipe page: an item's recipes or its uses, on one station's tab.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
struct Page {
|
||||
item: Stack,
|
||||
uses: bool,
|
||||
tab: usize,
|
||||
}
|
||||
|
||||
/// The viewer's state while a world is open.
|
||||
#[derive(Default)]
|
||||
pub struct Viewer {
|
||||
items: Vec<Item>,
|
||||
all: Vec<Recipe>,
|
||||
/// The panel's items now: indexes into `items`, after the search.
|
||||
listed: Vec<usize>,
|
||||
search: String,
|
||||
/// The page shown, then the ones Back returns to.
|
||||
history: Vec<Page>,
|
||||
/// The book id behind each recipe the page shows (-1: none).
|
||||
shown: Vec<i32>,
|
||||
}
|
||||
|
||||
impl Viewer {
|
||||
/// The world's tables: the panel lists every item.
|
||||
pub fn new(items: Vec<Item>, all: Vec<Recipe>) -> (Self, Out) {
|
||||
let mut me = Viewer {
|
||||
items,
|
||||
all,
|
||||
..Viewer::default()
|
||||
};
|
||||
let out = me.list();
|
||||
(me, out)
|
||||
}
|
||||
|
||||
fn id(&self, s: &Ingredient) -> &str {
|
||||
self.items
|
||||
.get(s.item as usize)
|
||||
.map_or("minecraft:air", |i| &i.id)
|
||||
}
|
||||
fn convert(&self, s: &Ingredient) -> Stack {
|
||||
Stack {
|
||||
item: self.id(s).into(),
|
||||
count: s.count,
|
||||
components: s.components.clone(),
|
||||
}
|
||||
}
|
||||
/// The same item, and the same components when `want` names some.
|
||||
fn matches(&self, s: &Ingredient, want: &Stack) -> bool {
|
||||
self.id(s) == want.item && (want.components.is_empty() || s.components == want.components)
|
||||
}
|
||||
fn name<'a>(&'a self, item: &'a str) -> &'a str {
|
||||
self.items
|
||||
.iter()
|
||||
.find(|i| i.id == item)
|
||||
.map_or(item, |i| &i.name)
|
||||
}
|
||||
|
||||
/// The items the search keeps: every word must be in the name or the
|
||||
/// id; `@minecraft` keeps a namespace, `#logs` a tag.
|
||||
fn list(&mut self) -> Out {
|
||||
let words: Vec<String> = self
|
||||
.search
|
||||
.to_lowercase()
|
||||
.split_whitespace()
|
||||
.map(String::from)
|
||||
.collect();
|
||||
let keeps = |item: &Item| {
|
||||
words.iter().all(|w| {
|
||||
if let Some(ns) = w.strip_prefix('@') {
|
||||
item.id.split(':').next().is_some_and(|n| n.starts_with(ns))
|
||||
} else if let Some(tag) = w.strip_prefix('#') {
|
||||
item.tags.iter().any(|t| t.contains(tag))
|
||||
} else {
|
||||
item.name.to_lowercase().contains(w.as_str()) || item.id.contains(w.as_str())
|
||||
}
|
||||
})
|
||||
};
|
||||
self.listed = (self.items.iter().enumerate())
|
||||
.filter(|(_, i)| i.id != "minecraft:air" && keeps(i))
|
||||
.map(|(n, _)| n)
|
||||
.collect();
|
||||
Out::Items(
|
||||
self.listed
|
||||
.iter()
|
||||
.map(|&n| stack(&self.items[n].id))
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
/// The recipes a page lists, all stations together.
|
||||
fn found(&self, page: &Page) -> Vec<&Recipe> {
|
||||
(self.all.iter())
|
||||
.filter(|r| {
|
||||
if page.uses {
|
||||
r.slots
|
||||
.iter()
|
||||
.flatten()
|
||||
.any(|s| self.matches(s, &page.item))
|
||||
} else {
|
||||
r.results.iter().any(|s| self.matches(s, &page.item))
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Opens a page, pushing it on the history; nothing when it is empty.
|
||||
fn open(&mut self, item: Stack, uses: bool, known: &[i32]) -> Option<Out> {
|
||||
let item = Stack { count: 1, ..item };
|
||||
let page = Page { item, uses, tab: 0 };
|
||||
if self.found(&page).is_empty() {
|
||||
return None;
|
||||
}
|
||||
self.history.push(page);
|
||||
Some(self.show(known))
|
||||
}
|
||||
|
||||
/// The page on top of the history, or no page when there is none.
|
||||
fn show(&mut self, known: &[i32]) -> Out {
|
||||
let Some(mut page) = self.history.last().cloned() else {
|
||||
self.shown.clear();
|
||||
return Out::Recipes {
|
||||
title: String::new(),
|
||||
tabs: Vec::new(),
|
||||
tab: 0,
|
||||
views: Vec::new(),
|
||||
};
|
||||
};
|
||||
let found = self.found(&page);
|
||||
let stations: Vec<usize> = (0..STATIONS.len())
|
||||
.filter(|&s| found.iter().any(|r| station(r.kind) == s))
|
||||
.collect();
|
||||
page.tab = page.tab.min(stations.len().saturating_sub(1));
|
||||
let on_tab = |r: &&&Recipe| stations.get(page.tab) == Some(&station(r.kind));
|
||||
let on: Vec<&Recipe> = found
|
||||
.iter()
|
||||
.filter(on_tab)
|
||||
.copied()
|
||||
.take(MAX_RECIPES)
|
||||
.collect();
|
||||
let total = found.iter().filter(on_tab).count();
|
||||
let views = on.iter().map(|r| self.view(r, known)).collect();
|
||||
let mut title = format!(
|
||||
"{}: {}",
|
||||
if page.uses { "Uses" } else { "Recipes" },
|
||||
self.name(&page.item.item)
|
||||
);
|
||||
if total > on.len() {
|
||||
title.push_str(&format!(" ({} of {total})", on.len()));
|
||||
}
|
||||
let tabs = stations.iter().map(|&s| stack(STATIONS[s])).collect();
|
||||
self.shown = on.iter().map(|r| r.book_id).collect();
|
||||
if let Some(top) = self.history.last_mut() {
|
||||
top.tab = page.tab;
|
||||
}
|
||||
Out::Recipes {
|
||||
title,
|
||||
tabs,
|
||||
tab: page.tab,
|
||||
views,
|
||||
}
|
||||
}
|
||||
|
||||
fn view(&self, r: &Recipe, known: &[i32]) -> View {
|
||||
let mut slots: Vec<Vec<Stack>> = (r.slots.iter())
|
||||
.map(|s| s.iter().take(MAX_STACKS).map(|s| self.convert(s)).collect())
|
||||
.collect();
|
||||
let results = (r.results.iter().take(MAX_STACKS))
|
||||
.map(|s| self.convert(s))
|
||||
.collect();
|
||||
let (layout, width, height, note) = match r.kind {
|
||||
Kind::CraftingShaped => (Layout::Grid, r.width, r.height, String::new()),
|
||||
Kind::CraftingShapeless => {
|
||||
let n = slots.len() as u32;
|
||||
let width = match n {
|
||||
0 | 1 => 1,
|
||||
2..=4 => 2,
|
||||
_ => 3,
|
||||
};
|
||||
let height = n.div_ceil(width).max(1);
|
||||
slots.resize((width * height) as usize, Vec::new());
|
||||
(Layout::Grid, width, height, "Shapeless".into())
|
||||
}
|
||||
Kind::Smelting | Kind::Blasting | Kind::Smoking | Kind::Campfire => {
|
||||
let seconds = r.cooking_time as f32 / 20.0;
|
||||
let note = format!("{seconds} s, {} XP", (r.experience * 100.0).round() / 100.0);
|
||||
(Layout::Cooking, 1, 1, note)
|
||||
}
|
||||
Kind::Stonecutting => (Layout::Single, 1, 1, String::new()),
|
||||
Kind::Smithing => (Layout::Smithing, 3, 1, String::new()),
|
||||
Kind::Brewing => (Layout::Brewing, 2, 1, String::new()),
|
||||
};
|
||||
// + only for what the recipe book shows; greyed until the book has it.
|
||||
let action = (r.book_id >= 0).then(|| known.contains(&r.book_id));
|
||||
View {
|
||||
layout,
|
||||
width,
|
||||
height,
|
||||
slots,
|
||||
results,
|
||||
note,
|
||||
action,
|
||||
}
|
||||
}
|
||||
|
||||
/// The player's action; `give` is the setting, `creative` whether the
|
||||
/// game would give. `known` is the recipe book's ids now.
|
||||
pub fn handle(&mut self, event: Event, give: bool, creative: bool, known: &[i32]) -> Vec<Out> {
|
||||
match event {
|
||||
Event::Search(text) => {
|
||||
self.search = text;
|
||||
vec![self.list()]
|
||||
}
|
||||
Event::Clicked { index, right } => {
|
||||
let Some(&n) = self.listed.get(index) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let item = self.items[n].id.clone();
|
||||
if give && creative {
|
||||
let count = if right { STACK } else { 1 };
|
||||
return vec![Out::Give { item, count }];
|
||||
}
|
||||
self.open(stack(&item), right, known).into_iter().collect()
|
||||
}
|
||||
Event::Key { uses, stack } => self.open(stack, uses, known).into_iter().collect(),
|
||||
Event::SlotClicked { stack, right } => {
|
||||
self.open(stack, right, known).into_iter().collect()
|
||||
}
|
||||
Event::Tab(i) => {
|
||||
if let Some(top) = self.history.last_mut() {
|
||||
top.tab = i;
|
||||
}
|
||||
vec![self.show(known)]
|
||||
}
|
||||
Event::Action(i) => match self.shown.get(i) {
|
||||
Some(&book) if book >= 0 => vec![Out::Place(book)],
|
||||
_ => Vec::new(),
|
||||
},
|
||||
Event::Back => {
|
||||
self.history.pop();
|
||||
vec![self.show(known)]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// "Click gives items" from its stored value (the WebAssembly mod's
|
||||
/// `true`/`false`); off by default.
|
||||
pub fn gives(stored: Option<String>) -> bool {
|
||||
stored.is_some_and(|v| v.trim() == "true")
|
||||
}
|
||||
|
||||
/// The settings page's button.
|
||||
pub fn give_label(give: bool) -> String {
|
||||
format!(
|
||||
"Click gives items (creative): {}",
|
||||
if give { "ON" } else { "OFF" }
|
||||
)
|
||||
}
|
||||
|
||||
std::thread_local! {
|
||||
/// The viewer while a world is open; the game is one thread.
|
||||
static VIEWER: std::cell::RefCell<Option<Viewer>> = const { std::cell::RefCell::new(None) };
|
||||
}
|
||||
|
||||
/// Runs `f` on the viewer, if a world is open.
|
||||
pub fn with_viewer<R>(f: impl FnOnce(&mut Viewer) -> R) -> Option<R> {
|
||||
VIEWER.with(|v| v.borrow_mut().as_mut().map(f))
|
||||
}
|
||||
|
||||
/// Whether the viewer holds a world's tables.
|
||||
pub fn loaded() -> bool {
|
||||
VIEWER.with(|v| v.borrow().is_some())
|
||||
}
|
||||
|
||||
/// Keeps the viewer of a world just opened (or none when it closed).
|
||||
pub fn set_viewer(viewer: Option<Viewer>) {
|
||||
VIEWER.with(|v| *v.borrow_mut() = viewer);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn tables() -> (Vec<Item>, Vec<Recipe>) {
|
||||
let item = |id: &str, name: &str, tags: &[&str]| Item {
|
||||
id: id.into(),
|
||||
name: name.into(),
|
||||
tags: tags.iter().map(|t| t.to_string()).collect(),
|
||||
};
|
||||
let items = vec![
|
||||
item("minecraft:air", "Air", &[]),
|
||||
item("minecraft:oak_log", "Oak Log", &["minecraft:logs"]),
|
||||
item("minecraft:oak_planks", "Oak Planks", &["minecraft:planks"]),
|
||||
item("minecraft:stick", "Stick", &[]),
|
||||
item("minecraft:charcoal", "Charcoal", &[]),
|
||||
];
|
||||
let one = |item| Ingredient {
|
||||
item,
|
||||
count: 1,
|
||||
components: String::new(),
|
||||
};
|
||||
let planks = Recipe {
|
||||
kind: Kind::CraftingShapeless,
|
||||
width: 0,
|
||||
height: 0,
|
||||
slots: vec![vec![one(1)]],
|
||||
results: vec![Ingredient { count: 4, ..one(2) }],
|
||||
cooking_time: 0,
|
||||
experience: 0.0,
|
||||
book_id: 7,
|
||||
};
|
||||
let sticks = Recipe {
|
||||
kind: Kind::CraftingShaped,
|
||||
width: 1,
|
||||
height: 2,
|
||||
slots: vec![vec![one(2)], vec![one(2)]],
|
||||
results: vec![Ingredient { count: 4, ..one(3) }],
|
||||
book_id: 8,
|
||||
..planks.clone()
|
||||
};
|
||||
let charcoal = Recipe {
|
||||
kind: Kind::Smelting,
|
||||
width: 0,
|
||||
height: 0,
|
||||
slots: vec![vec![one(1)]],
|
||||
results: vec![one(4)],
|
||||
cooking_time: 200,
|
||||
experience: 0.15,
|
||||
book_id: 9,
|
||||
};
|
||||
(items, vec![planks, sticks, charcoal])
|
||||
}
|
||||
|
||||
fn ids(out: &Out) -> Vec<&str> {
|
||||
match out {
|
||||
Out::Items(items) => items.iter().map(|s| s.item.as_str()).collect(),
|
||||
_ => panic!("{out:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lists_every_item_but_air_and_searches_names_ids_namespaces_and_tags() {
|
||||
let (items, all) = tables();
|
||||
let (mut v, out) = Viewer::new(items, all);
|
||||
assert_eq!(ids(&out).len(), 4);
|
||||
let search = |v: &mut Viewer, text: &str| {
|
||||
ids(&v.handle(Event::Search(text.into()), false, false, &[])[0]).len()
|
||||
};
|
||||
assert_eq!(search(&mut v, "oak"), 2);
|
||||
assert_eq!(search(&mut v, "#logs"), 1);
|
||||
assert_eq!(search(&mut v, "@mine stick"), 1);
|
||||
assert_eq!(search(&mut v, "@other"), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_click_shows_recipes_and_a_right_click_uses_with_a_tab_per_station() {
|
||||
let (items, all) = tables();
|
||||
let (mut v, _) = Viewer::new(items, all);
|
||||
// Oak Log is listed first: it has no recipe, so nothing opens.
|
||||
assert!(
|
||||
v.handle(
|
||||
Event::Clicked {
|
||||
index: 0,
|
||||
right: false
|
||||
},
|
||||
false,
|
||||
false,
|
||||
&[]
|
||||
)
|
||||
.is_empty()
|
||||
);
|
||||
let uses = v.handle(
|
||||
Event::Clicked {
|
||||
index: 0,
|
||||
right: true,
|
||||
},
|
||||
false,
|
||||
false,
|
||||
&[8],
|
||||
);
|
||||
let Out::Recipes {
|
||||
title,
|
||||
tabs,
|
||||
tab,
|
||||
views,
|
||||
} = &uses[0]
|
||||
else {
|
||||
panic!()
|
||||
};
|
||||
assert_eq!(title, "Uses: Oak Log");
|
||||
assert_eq!(
|
||||
tabs.iter().map(|t| t.item.as_str()).collect::<Vec<_>>(),
|
||||
[STATIONS[0], STATIONS[1]]
|
||||
);
|
||||
assert_eq!(*tab, 0);
|
||||
// Shapeless planks laid out in a 1 x 1 grid; the book doesn't hold it yet.
|
||||
assert_eq!(
|
||||
(views[0].layout, views[0].width, views[0].note.as_str()),
|
||||
(Layout::Grid, 1, "Shapeless")
|
||||
);
|
||||
assert_eq!(views[0].action, Some(false));
|
||||
let furnace = v.handle(Event::Tab(1), false, false, &[]);
|
||||
let Out::Recipes { views, .. } = &furnace[0] else {
|
||||
panic!()
|
||||
};
|
||||
assert_eq!(
|
||||
(views[0].layout, views[0].note.as_str()),
|
||||
(Layout::Cooking, "10 s, 0.15 XP")
|
||||
);
|
||||
assert_eq!(
|
||||
v.handle(Event::Action(0), false, false, &[]),
|
||||
[Out::Place(9)]
|
||||
);
|
||||
// Back with one page shown: no page, the items again.
|
||||
let back = v.handle(Event::Back, false, false, &[]);
|
||||
assert!(matches!(&back[0], Out::Recipes { views, .. } if views.is_empty()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keys_and_slot_clicks_open_pages_and_back_returns() {
|
||||
let (items, all) = tables();
|
||||
let (mut v, _) = Viewer::new(items, all);
|
||||
let stick = Stack {
|
||||
count: 64,
|
||||
..stack("minecraft:stick")
|
||||
};
|
||||
let out = v.handle(
|
||||
Event::Key {
|
||||
uses: false,
|
||||
stack: stick,
|
||||
},
|
||||
false,
|
||||
false,
|
||||
&[8],
|
||||
);
|
||||
let Out::Recipes { title, views, .. } = &out[0] else {
|
||||
panic!()
|
||||
};
|
||||
assert_eq!(title, "Recipes: Stick");
|
||||
assert_eq!(views[0].action, Some(true));
|
||||
let planks = v.handle(
|
||||
Event::SlotClicked {
|
||||
stack: stack("minecraft:oak_planks"),
|
||||
right: false,
|
||||
},
|
||||
false,
|
||||
false,
|
||||
&[],
|
||||
);
|
||||
assert!(matches!(&planks[0], Out::Recipes { title, .. } if title == "Recipes: Oak Planks"));
|
||||
let back = v.handle(Event::Back, false, false, &[]);
|
||||
assert!(matches!(&back[0], Out::Recipes { title, .. } if title == "Recipes: Stick"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn in_creative_with_the_setting_a_click_gives() {
|
||||
let (items, all) = tables();
|
||||
let (mut v, _) = Viewer::new(items, all);
|
||||
let give = |v: &mut Viewer, right, give, creative| {
|
||||
v.handle(Event::Clicked { index: 2, right }, give, creative, &[])
|
||||
};
|
||||
assert_eq!(
|
||||
give(&mut v, false, true, true),
|
||||
[Out::Give {
|
||||
item: "minecraft:stick".into(),
|
||||
count: 1
|
||||
}]
|
||||
);
|
||||
assert_eq!(
|
||||
give(&mut v, true, true, true),
|
||||
[Out::Give {
|
||||
item: "minecraft:stick".into(),
|
||||
count: STACK
|
||||
}]
|
||||
);
|
||||
// Off, or not in creative: the page as usual.
|
||||
assert!(matches!(
|
||||
&give(&mut v, false, false, true)[0],
|
||||
Out::Recipes { .. }
|
||||
));
|
||||
assert!(matches!(
|
||||
&give(&mut v, false, true, false)[0],
|
||||
Out::Recipes { .. }
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_setting_reads_the_webassembly_mods_value() {
|
||||
assert!(gives(Some("true".into())));
|
||||
assert!(!gives(Some("false".into())));
|
||||
assert!(!gives(None));
|
||||
assert_eq!(give_label(true), "Click gives items (creative): ON");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
// The mod's settings page: the WebAssembly mod's one button.
|
||||
fn official_recipes_page(
|
||||
game: &mut crate::game::Game<'_>,
|
||||
mod_id: &str,
|
||||
buttons: &mut Vec<String>,
|
||||
) {
|
||||
use crate::game::official_recipes as m;
|
||||
if mod_id != m::ID {
|
||||
return;
|
||||
}
|
||||
buttons.push(m::give_label(m::gives(game.native_setting(m::ID, "give"))));
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
"""Recipes in a running modded game: the WebAssembly mod's setting is read and written as it was, the panel
|
||||
beside a crafting table's screen shows items (container screens show it; the creative inventory doesn't), a
|
||||
click on one gives it in creative mode, and with the setting off the same click opens its recipe page."""
|
||||
import json
|
||||
import time
|
||||
from game import Game, difference
|
||||
|
||||
DATA = "horizon-mods/.horizon-mod-data/recipes"
|
||||
# A player's setting from the WebAssembly mod: "Click gives items" on.
|
||||
FILES = {f"{DATA}/give": "true"}
|
||||
SLOT = 18
|
||||
|
||||
|
||||
def first_item(g):
|
||||
"""The panel's first item (Stone, the first item of the registry): its centre in GUI pixels, and the panel's
|
||||
part of the screen."""
|
||||
ui = g.run("ui")
|
||||
width = ui["gui_size"][0]
|
||||
stone = next(w for w in ui["widgets"] if w.get("label") == "Stone" and w.get("kind") == "button")
|
||||
x, y, w, h = stone["bounds"]
|
||||
return x + w / 2, y + h / 2, ((x - 2 * SLOT) / width, 0.0, 1.0, 1.0)
|
||||
|
||||
|
||||
def table(g):
|
||||
"""Opens the crafting table placed beside the player."""
|
||||
x, y, z = (int(c // 1) for c in g.run("status")["player"]["position"])
|
||||
if not getattr(g, "table", None):
|
||||
g.run("command", f"setblock {x + 2} {y} {z} minecraft:crafting_table")
|
||||
g.table = (x + 2, y, z)
|
||||
time.sleep(1)
|
||||
g.run("interact", *map(str, g.table))
|
||||
time.sleep(1)
|
||||
assert g.run("screen").get("screen") not in ("playing", "creative"), g.run("screen")
|
||||
|
||||
|
||||
def run(ctl, resources, out):
|
||||
with Game(ctl, resources, out, FILES) as g:
|
||||
g.run("options", "mods")
|
||||
assert "Recipes 2.0.0: ON" in g.labels(), g.labels()
|
||||
g.run("click", g.ids()["Settings"])
|
||||
page = g.labels()
|
||||
assert "Click gives items (creative): ON" in page, page
|
||||
g.play()
|
||||
g.run("command", "clear @s")
|
||||
table(g)
|
||||
x, y, panel = first_item(g)
|
||||
shown = g.shot("1-panel")
|
||||
g.run("click-at", str(x), str(y))
|
||||
time.sleep(1)
|
||||
given = g.run("inventory").get("counts", {})
|
||||
assert given, "a click on the panel's first item gave nothing"
|
||||
g.play()
|
||||
g.run("options", "mods")
|
||||
g.run("click", g.ids()["Settings"])
|
||||
g.run("click", g.ids()["Click gives items (creative): ON"])
|
||||
stored = (g.home / DATA / "give").read_text()
|
||||
g.play()
|
||||
table(g)
|
||||
before = g.shot("2-items")
|
||||
g.run("click-at", str(x), str(y))
|
||||
time.sleep(1)
|
||||
after = g.shot("3-recipe-page")
|
||||
results = {
|
||||
"given": given,
|
||||
"give_file_after_click": stored,
|
||||
"diff_panel": round(difference(before, after, panel), 1),
|
||||
"diff_panel_first_shot": round(difference(shown, before, panel), 1),
|
||||
}
|
||||
assert stored == "false", results
|
||||
assert results["diff_panel"] > 4, results
|
||||
print(json.dumps({"proof": "recipes", "ok": True, **results}, indent=2))
|
||||
@@ -0,0 +1,117 @@
|
||||
"""A modded game driven with horizon-ctl, on fresh data: a temporary game folder (the game keeps its
|
||||
options, worlds and mods' settings in its working folder) and temporary launcher and XDG folders, with
|
||||
no audio and no self-update. Nothing touches the player's own data."""
|
||||
import json
|
||||
import os
|
||||
import struct
|
||||
import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
import zlib
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class Game:
|
||||
def __init__(self, ctl, resources, out, files=None):
|
||||
self.ctl, self.resources, self.out = Path(ctl), Path(resources), Path(out)
|
||||
self.files = files or {}
|
||||
|
||||
def __enter__(self):
|
||||
self.out.mkdir(parents=True, exist_ok=True)
|
||||
self.dirs = [tempfile.mkdtemp(prefix=f"native-proof-{n}-") for n in ("home", "launcher", "data", "config")]
|
||||
self.home = Path(self.dirs[0])
|
||||
for name, text in self.files.items():
|
||||
(self.home / name).parent.mkdir(parents=True, exist_ok=True)
|
||||
(self.home / name).write_text(text)
|
||||
self.socket = str(Path(tempfile.mkdtemp(prefix="native-ctl-")) / "ctl.sock")
|
||||
self.env = dict(os.environ, HORIZON_HOME=self.dirs[0], HORIZON_LAUNCHER_HOME=self.dirs[1],
|
||||
XDG_DATA_HOME=self.dirs[2], XDG_CONFIG_HOME=self.dirs[3],
|
||||
HORIZON_LAUNCHER_NO_SELF_UPDATE="1", HORIZON_NO_AUDIO="1")
|
||||
self.run("launch", "--world", "Proof", "--creative", "--offscreen", "--wait",
|
||||
"--resources", str(self.resources))
|
||||
time.sleep(3)
|
||||
return self
|
||||
|
||||
def __exit__(self, *_):
|
||||
subprocess.run([str(self.ctl), "--socket", self.socket, "quit"], env=self.env, cwd=self.home,
|
||||
capture_output=True)
|
||||
|
||||
def run(self, *args):
|
||||
done = subprocess.run([str(self.ctl), "--socket", self.socket, *args], env=self.env, cwd=self.home,
|
||||
capture_output=True, text=True, check=True)
|
||||
try:
|
||||
return json.loads(done.stdout)
|
||||
except json.JSONDecodeError:
|
||||
return done.stdout
|
||||
|
||||
def play(self):
|
||||
"""Back to the world from any screen."""
|
||||
for _ in range(6):
|
||||
if self.run("status").get("screen") == "playing":
|
||||
return
|
||||
self.run("key", "esc", "tap")
|
||||
time.sleep(0.5)
|
||||
if self.run("status").get("screen") == "playing":
|
||||
return
|
||||
raise AssertionError(f"not back in the world: {self.run('status').get('screen')}")
|
||||
|
||||
def labels(self):
|
||||
return [w.get("label") for w in self.run("ui").get("widgets", [])]
|
||||
|
||||
def ids(self):
|
||||
return {w.get("label"): w.get("id") for w in self.run("ui").get("widgets", [])}
|
||||
|
||||
def shot(self, name):
|
||||
path = self.out / f"{name}.png"
|
||||
self.run("screenshot", str(path))
|
||||
return path
|
||||
|
||||
|
||||
def pixels(path):
|
||||
"""An 8-bit RGB or RGBA, non-interlaced PNG's (width, height, channels, rows)."""
|
||||
data = Path(path).read_bytes()
|
||||
pos, chunks, width = 8, b"", 0
|
||||
while pos < len(data):
|
||||
length, kind = struct.unpack(">I4s", data[pos:pos + 8])
|
||||
body = data[pos + 8:pos + 8 + length]
|
||||
if kind == b"IHDR":
|
||||
width, height, depth, color = struct.unpack(">IIBB", body[:10])
|
||||
channels = {2: 3, 6: 4}[color]
|
||||
assert depth == 8
|
||||
elif kind == b"IDAT":
|
||||
chunks += body
|
||||
pos += 12 + length
|
||||
raw, stride, rows, prev = zlib.decompress(chunks), width * channels, [], bytearray(width * channels)
|
||||
for y in range(height):
|
||||
start = y * (stride + 1)
|
||||
kind, line = raw[start], bytearray(raw[start + 1:start + 1 + stride])
|
||||
for i in range(stride):
|
||||
a = line[i - channels] if i >= channels else 0
|
||||
b, c = prev[i], prev[i - channels] if i >= channels else 0
|
||||
if kind == 1:
|
||||
line[i] = (line[i] + a) & 255
|
||||
elif kind == 2:
|
||||
line[i] = (line[i] + b) & 255
|
||||
elif kind == 3:
|
||||
line[i] = (line[i] + (a + b) // 2) & 255
|
||||
elif kind == 4:
|
||||
p = a + b - c
|
||||
pa, pb, pc = abs(p - a), abs(p - b), abs(p - c)
|
||||
line[i] = (line[i] + (a if pa <= pb and pa <= pc else b if pb <= pc else c)) & 255
|
||||
rows.append(line)
|
||||
prev = line
|
||||
return width, height, channels, rows
|
||||
|
||||
|
||||
def difference(a, b, box=(0.0, 0.0, 1.0, 1.0)):
|
||||
"""The mean difference of two screenshots' colours, 0..255, on a grid of samples, within `box`
|
||||
(left, top, right, bottom, as fractions of the screen)."""
|
||||
wa, ha, ca, ra = pixels(a)
|
||||
_, _, cb, rb = pixels(b)
|
||||
total = count = 0
|
||||
x0, y0, x1, y1 = int(box[0] * wa), int(box[1] * ha), int(box[2] * wa), int(box[3] * ha)
|
||||
for y in range(y0, y1, 4):
|
||||
for x in range(x0, x1, 4):
|
||||
total += sum(abs(ra[y][x * ca + k] - rb[y][x * cb + k]) for k in range(3))
|
||||
count += 3
|
||||
return total / count
|
||||
@@ -0,0 +1,243 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Build Horizon's native mods (native/<name>/) into Horizon, package them, and prove them in a running game.
|
||||
|
||||
python3 native/tools/native.py check zoom # apply on the pinned Horizon, cargo check the game
|
||||
python3 native/tools/native.py check zoom minimap ... # several mods applied together, as a player's set is
|
||||
python3 native/tools/native.py build zoom # ... and build it (fast profile); prints the binary
|
||||
python3 native/tools/native.py package zoom # native/target/<id>-<version>.hnmod for the registry
|
||||
python3 native/tools/native.py proof --resources PATH zoom # build, then drive the game with horizon-ctl
|
||||
--horizon REV build on another Horizon commit (`latest` = the newest Nightly) instead of the mods'
|
||||
compatibility.json: they use only the native hook points, so they build on any Horizon whose
|
||||
NATIVE_HOOKS_API is at least the one they name, as the launcher does. Without it, mods pinned
|
||||
to different commits build on the commit of those that need the most hook points.
|
||||
`all` names every mod in native/.
|
||||
|
||||
Horizon's source comes from GitHub's archive of the commit, never a local checkout; horizon-mixins is built once at
|
||||
the pinned commit. Everything goes under native/target/, shared by the mods.
|
||||
"""
|
||||
import argparse
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tarfile
|
||||
import tomllib
|
||||
import urllib.request
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
NATIVE = Path(__file__).resolve().parents[1]
|
||||
TARGET = NATIVE / "target"
|
||||
REPO = "horizon-rust/horizon"
|
||||
HOOKS_FILE = "crates/horizon-client/src/game/native_hooks.rs"
|
||||
HOOKS_LINE = re.compile(r"^pub\(crate\) const NATIVE_HOOKS_API: u32 = (\d+);$", re.M)
|
||||
|
||||
|
||||
class Mod:
|
||||
"""native/<name>/: its compatibility.json and its one mixin bundle."""
|
||||
|
||||
def __init__(self, name):
|
||||
self.root = NATIVE / name
|
||||
if not (self.root / "compatibility.json").is_file():
|
||||
sys.exit(f"no mod native/{name}/ (compatibility.json)")
|
||||
self.compat = json.loads((self.root / "compatibility.json").read_text())
|
||||
mixins = self.root / "mixins"
|
||||
self.bundle = next(p for p in mixins.iterdir() if (p / "horizon-mixin.toml").is_file())
|
||||
manifest = tomllib.loads((self.bundle / "horizon-mixin.toml").read_text())
|
||||
self.manifest = manifest
|
||||
self.id = manifest["mod"]["id"]
|
||||
self.version = manifest["mod"]["version"]
|
||||
|
||||
|
||||
def all_mods():
|
||||
return sorted(p.name for p in NATIVE.iterdir() if (p / "compatibility.json").is_file())
|
||||
|
||||
|
||||
def log(*args):
|
||||
print(*args, file=sys.stderr, flush=True)
|
||||
|
||||
|
||||
def get(url):
|
||||
request = urllib.request.Request(url, headers={"User-Agent": "horizon-native-mod"})
|
||||
token = os.environ.get("GITHUB_TOKEN")
|
||||
if token and url.startswith("https://api.github.com/"):
|
||||
request.add_header("Authorization", f"Bearer {token}")
|
||||
with urllib.request.urlopen(request, timeout=300) as response:
|
||||
return response.read()
|
||||
|
||||
|
||||
def latest_nightly():
|
||||
"""The commit of the newest Nightly: its permanent nightly-source/<date>-<short> tag."""
|
||||
refs = json.loads(get(f"https://api.github.com/repos/{REPO}/git/matching-refs/tags/nightly-source/"))
|
||||
newest = max(refs, key=lambda r: r["ref"])
|
||||
return get_commit(newest["ref"].removeprefix("refs/tags/"))
|
||||
|
||||
|
||||
def get_commit(ref):
|
||||
return json.loads(get(f"https://api.github.com/repos/{REPO}/commits/{ref}"))["sha"]
|
||||
|
||||
|
||||
def source(revision):
|
||||
"""Horizon's source at `revision`, unpacked once into target/horizon/<revision>/."""
|
||||
out = TARGET / "horizon" / revision
|
||||
if (out / ".horizon-revision").is_file():
|
||||
return out
|
||||
log(f"Downloading Horizon {revision[:7]}")
|
||||
data = get(f"https://codeload.github.com/{REPO}/tar.gz/{revision}")
|
||||
partial = TARGET / "horizon" / f".{revision}.partial"
|
||||
shutil.rmtree(partial, ignore_errors=True)
|
||||
partial.mkdir(parents=True)
|
||||
with tarfile.open(fileobj=io.BytesIO(data), mode="r:gz") as archive:
|
||||
archive.extractall(partial, filter="data")
|
||||
top = next(partial.iterdir())
|
||||
# horizon-mixins refuses links; the source has a few (docs), none needed to build.
|
||||
for path in top.rglob("*"):
|
||||
if path.is_symlink():
|
||||
path.unlink()
|
||||
(top / ".horizon-revision").write_text(revision)
|
||||
shutil.rmtree(out, ignore_errors=True)
|
||||
top.rename(out)
|
||||
shutil.rmtree(partial)
|
||||
return out
|
||||
|
||||
|
||||
def hooks_api(tree):
|
||||
found = HOOKS_LINE.search((tree / HOOKS_FILE).read_text()) if (tree / HOOKS_FILE).is_file() else None
|
||||
return int(found.group(1)) if found else None
|
||||
|
||||
|
||||
def tool(mods):
|
||||
"""horizon-mixins at the mods' pinned commit, built once."""
|
||||
revs = {m.compat["horizon_mixins_revision"] for m in mods}
|
||||
if len(revs) != 1:
|
||||
sys.exit(f"the mods pin different horizon-mixins commits: {sorted(revs)}")
|
||||
rev = revs.pop()
|
||||
root = TARGET / "horizon-mixins" / rev
|
||||
binary = root / "bin" / "horizon-mixins"
|
||||
if not binary.is_file():
|
||||
log(f"Building horizon-mixins {rev[:7]}")
|
||||
repo = mods[0].compat["horizon_mixins_repository"]
|
||||
subprocess.run(["cargo", "install", "--locked", "--quiet", "--git", repo, "--rev", rev, "--root", str(root),
|
||||
"horizon-mixins-cli"], check=True)
|
||||
return binary
|
||||
|
||||
|
||||
def stage(mods, revision):
|
||||
"""The mods applied together on Horizon `revision`: target/stage/<ids>/ (cargo's incremental state survives)."""
|
||||
tree = source(revision)
|
||||
api = hooks_api(tree)
|
||||
# Hook points are only ever added: any later version has the ones these mods hook.
|
||||
for m in mods:
|
||||
if api is None or api < m.compat["hooks"]:
|
||||
sys.exit(f"Horizon {revision[:7]} has native hook points v{api}, {m.id} needs v{m.compat['hooks']}.")
|
||||
key = "+".join(m.id for m in mods)
|
||||
# As the launcher does for mods on the hook points: pin the copies to the commit they build on.
|
||||
bundles = TARGET / "mods" / key
|
||||
shutil.rmtree(bundles, ignore_errors=True)
|
||||
for m in mods:
|
||||
shutil.copytree(m.bundle, bundles / m.id)
|
||||
toml = bundles / m.id / "horizon-mixin.toml"
|
||||
toml.write_text(re.sub(r'horizon_revision = "[0-9a-f]{40}"', f'horizon_revision = "{revision}"',
|
||||
toml.read_text()))
|
||||
fresh = (TARGET / "stage.new").resolve()
|
||||
shutil.rmtree(fresh, ignore_errors=True)
|
||||
subprocess.run([str(tool(mods)), "apply", "--workspace", str(tree), "--mods", str(bundles), "--out", str(fresh)],
|
||||
check=True, stdout=subprocess.DEVNULL)
|
||||
out = TARGET / "stage" / key
|
||||
out.mkdir(parents=True, exist_ok=True)
|
||||
# Only changed files are replaced, so cargo rebuilds only what changed.
|
||||
subprocess.run(["rsync", "-a", "--checksum", "--delete", f"{fresh}/", f"{out}/"], check=True)
|
||||
shutil.rmtree(fresh)
|
||||
return out
|
||||
|
||||
|
||||
def cargo(stage_dir, action):
|
||||
jobs = os.environ.get("FLEET_LOAD_JOBS") or os.environ.get("CARGO_BUILD_JOBS")
|
||||
command = ["cargo", action, "--locked", "-p", "horizon-client", "--bin", "horizon-client"]
|
||||
if action == "build":
|
||||
command += ["--bin", "horizon-ctl", "--profile", "fast"]
|
||||
if os.environ.get("NATIVE_NO_DEFAULT_FEATURES"):
|
||||
command.append("--no-default-features")
|
||||
if jobs:
|
||||
command += ["-j", jobs]
|
||||
env = dict(os.environ, CARGO_TARGET_DIR=str(TARGET / "cargo"), RUSTFLAGS="-D warnings")
|
||||
subprocess.run(command, cwd=stage_dir, env=env, check=True)
|
||||
return TARGET / "cargo" / "fast"
|
||||
|
||||
|
||||
def package(m):
|
||||
"""A deterministic .hnmod: horizon-native.json and the bundle's .rs and .toml files."""
|
||||
manifest = {"schema": 1, "id": m.id, "version": m.version, "game": m.compat["game"],
|
||||
"horizon": m.compat["horizon_revision"], "mixins": m.compat["horizon_mixins_revision"],
|
||||
"hooks": m.compat["hooks"]}
|
||||
pinned = m.manifest.get("compatibility", {}).get("horizon_revision")
|
||||
if pinned != m.compat["horizon_revision"]:
|
||||
sys.exit(f"{m.id}: horizon-mixin.toml pins {pinned}, compatibility.json {m.compat['horizon_revision']}")
|
||||
files = {"horizon-native.json": (json.dumps(manifest, indent=2, sort_keys=True) + "\n").encode()}
|
||||
for path in sorted(m.bundle.iterdir()):
|
||||
if path.suffix in (".rs", ".toml"):
|
||||
files[f"mixins/{m.id}/{path.name}"] = path.read_bytes()
|
||||
out = TARGET / f"{m.id}-{m.version}.hnmod"
|
||||
TARGET.mkdir(exist_ok=True)
|
||||
with zipfile.ZipFile(out, "w", compression=zipfile.ZIP_DEFLATED) as archive:
|
||||
for name, content in sorted(files.items()):
|
||||
info = zipfile.ZipInfo(name, (2026, 1, 1, 0, 0, 0))
|
||||
info.compress_type = zipfile.ZIP_DEFLATED
|
||||
info.external_attr = 0o100644 << 16
|
||||
archive.writestr(info, content)
|
||||
print(json.dumps({"file": str(out), "size": out.stat().st_size,
|
||||
"sha256": hashlib.sha256(out.read_bytes()).hexdigest()}, indent=2))
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
parser.add_argument("action", choices=["check", "build", "package", "proof"])
|
||||
parser.add_argument("mods", nargs="+", help="folders of native/ (`all`: every one)")
|
||||
parser.add_argument("--horizon", help="a Horizon commit, `latest` (newest Nightly) or a tag (default: the mods' own)")
|
||||
parser.add_argument("--resources", help="prepared resources (proof): .local/horizon-client/26.3")
|
||||
args = parser.parse_args()
|
||||
names = all_mods() if args.mods == ["all"] else args.mods
|
||||
mods = [Mod(n) for n in names]
|
||||
if args.action == "package":
|
||||
for m in mods:
|
||||
package(m)
|
||||
return
|
||||
if args.horizon is None:
|
||||
# Mods on the hook points build on any Horizon with the hook points they need, as the launcher builds
|
||||
# them: the commit of the mods that need the most has them all (stage checks each mod's need).
|
||||
newest = max(m.compat["hooks"] for m in mods)
|
||||
revs = {m.compat["horizon_revision"] for m in mods if m.compat["hooks"] == newest}
|
||||
if len(revs) != 1:
|
||||
sys.exit(f"the mods were tested on different Horizons {sorted(revs)}: pass --horizon")
|
||||
revision = revs.pop()
|
||||
else:
|
||||
revision = latest_nightly() if args.horizon == "latest" else args.horizon
|
||||
if not re.fullmatch(r"[0-9a-f]{40}", revision):
|
||||
revision = get_commit(revision)
|
||||
log(f"{', '.join(f'{m.id} {m.version}' for m in mods)} on Horizon {revision[:7]}")
|
||||
out = stage(mods, revision)
|
||||
if args.action == "check":
|
||||
cargo(out, "check")
|
||||
print(json.dumps({"mods": [m.id for m in mods], "horizon": revision, "check": "ok"}))
|
||||
return
|
||||
binaries = cargo(out, "build")
|
||||
if args.action == "build":
|
||||
print(json.dumps({"mods": [m.id for m in mods], "horizon": revision, "game": str(binaries / "horizon-client")}))
|
||||
return
|
||||
if not args.resources:
|
||||
sys.exit("proof needs --resources (prepared resources for Minecraft 26.3)")
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
import importlib.util
|
||||
for m in mods:
|
||||
spec = importlib.util.spec_from_file_location(f"proof_{m.id}", m.root / "proof.py")
|
||||
proof = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(proof)
|
||||
proof.run(binaries / "horizon-ctl", Path(args.resources).resolve(), TARGET / "proof" / m.id)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Generated
+7
@@ -0,0 +1,7 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "horizon-mod-zoom"
|
||||
version = "2.0.0"
|
||||
@@ -0,0 +1,11 @@
|
||||
[package]
|
||||
name = "horizon-mod-zoom"
|
||||
version = "2.0.0"
|
||||
edition = "2024"
|
||||
license = "MIT"
|
||||
publish = false
|
||||
description = "Horizon's official Zoom, a native mod: hold a key to zoom, the wheel changes it while held."
|
||||
|
||||
# The mod's logic and its tests: the same file the bundle adds to the game.
|
||||
[lib]
|
||||
path = "mixins/zoom/runtime.rs"
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Ludovic Marie and the Horizon contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,50 @@
|
||||
# Zoom (Horizon native mod)
|
||||
|
||||
Horizon's official Zoom: hold a key (Z by default) to zoom in, and turn the mouse wheel while you hold it to zoom
|
||||
further in or out. Its settings page (Options > Mods > Zoom > Settings) has the key and three options: the
|
||||
starting zoom (2x to 10x), smooth zoom (eased like the spyglass) and slower turning while zoomed.
|
||||
|
||||
It is a [horizon-mixins](https://github.com/horizon-rust/horizon-mixins) bundle (`mixins/zoom/`) that the Horizon
|
||||
launcher builds into the game. It replaces the WebAssembly Zoom 1.x and keeps its settings: the same files
|
||||
(`horizon-mods/.horizon-mod-data/zoom/level`, `smooth`, `sensitivity`) and the same key line
|
||||
(`key.zoom.zoom:…` in `horizon-mods.txt`).
|
||||
|
||||
## Install
|
||||
|
||||
In the Horizon launcher: Mods → Zoom → Install, then Settings → Game → Native mods (experimental) → Build. The
|
||||
launcher downloads its own Rust and builds the game with your native mods; see Horizon's
|
||||
[native mods](https://github.com/horizon-rust/horizon/blob/main/docs/native-mods.md).
|
||||
|
||||
## Made on the hook points
|
||||
|
||||
The bundle hooks only Horizon's native hook points (`crates/horizon-client/src/game/native_hooks.rs`), which keep
|
||||
their names and signatures across releases. `compatibility.json` names the version it needs (`"hooks": 1`), so the
|
||||
launcher builds Zoom on any Horizon with that version or a later one, the player's own game by default, together with any other
|
||||
mod. `horizon_revision` is only the commit it was last tested on.
|
||||
|
||||
| File | What it is |
|
||||
|---|---|
|
||||
| `mixins/zoom/horizon-mixin.toml` | The bundle: one module and the HEAD hooks |
|
||||
| `mixins/zoom/runtime.rs` | The mod's logic and state, added to the game as `game::official_zoom`; also this crate's library, for its tests |
|
||||
| `mixins/zoom/*.rs` | One small helper per hook |
|
||||
| `../tools/native.py` | Shared by the native mods: apply on Horizon (from GitHub, by commit), check, build, package the `.hnmod`, run the proof |
|
||||
| `proof.py` (with `../tools/game.py`) | The proof: the built game driven with `horizon-ctl --offscreen` on fresh, temporary data |
|
||||
|
||||
## Build and prove
|
||||
|
||||
```sh
|
||||
(cd native/zoom && cargo test) # the logic
|
||||
python3 native/tools/native.py check zoom # apply on the tested Horizon and cargo check the game
|
||||
python3 native/tools/native.py check zoom --horizon latest # ... on the newest Nightly
|
||||
python3 native/tools/native.py check all # with the other native mods, as a player's set
|
||||
python3 native/tools/native.py package zoom # native/target/zoom-<version>.hnmod for the registry
|
||||
python3 native/tools/native.py proof zoom --resources /path/to/.local/horizon-client/26.3
|
||||
```
|
||||
|
||||
The proof needs resources prepared from your own Minecraft 26.3 (`horizon-prepare`); none are in this repository.
|
||||
It seeds the WebAssembly Zoom's settings (8x, no smoothing, key C) in a temporary game folder, then checks that
|
||||
Options > Mods shows them, that Z does nothing and C zooms, that the wheel zooms instead of moving the hotbar
|
||||
while C is held, and that the page's buttons write the same files. CI runs `cargo test`, packages the `.hnmod`
|
||||
and checks the game on the tested commit, and every Monday on the newest Nightly.
|
||||
|
||||
License: MIT.
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"game": "26.3",
|
||||
"hooks": 1,
|
||||
"horizon_repository": "https://github.com/horizon-rust/horizon",
|
||||
"horizon_revision": "6f8c7a0e5d531d3834c4d0be4659a1db8e7e5c2a",
|
||||
"horizon_mixins_repository": "https://github.com/horizon-rust/horizon-mixins",
|
||||
"horizon_mixins_revision": "0d6b103c70633d8fd9443d8d0acd3aadaacedb06"
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
// A click on Zoom's settings page: change the setting and keep it.
|
||||
fn zoom_click(game: &mut crate::game::Game<'_>, mod_id: &str, button: usize) {
|
||||
use crate::game::official_zoom as zoom;
|
||||
if mod_id != zoom::ID {
|
||||
return;
|
||||
}
|
||||
let mut settings = zoom::settings(|key| game.native_setting(zoom::ID, key));
|
||||
settings.click(button);
|
||||
zoom::set_settings(settings);
|
||||
for (key, value) in settings.stored() {
|
||||
game.native_set_setting(zoom::ID, key, &value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
[mod]
|
||||
id = "zoom"
|
||||
version = "2.0.0"
|
||||
priority = 100
|
||||
|
||||
# The commit it was tested on. It uses only the native hook points (hooks 1 in
|
||||
# compatibility.json): the launcher builds it on any Horizon that has them.
|
||||
[compatibility]
|
||||
horizon_revision = "6f8c7a0e5d531d3834c4d0be4659a1db8e7e5c2a"
|
||||
|
||||
[[module]]
|
||||
id = "runtime"
|
||||
file = "crates/horizon-client/src/game.rs"
|
||||
name = "official_zoom"
|
||||
source = "runtime.rs"
|
||||
|
||||
[[injection]]
|
||||
id = "key"
|
||||
kind = "HEAD"
|
||||
target = "horizon_client::game::native_hooks::Game<'_>::native_key"
|
||||
helper = "key.rs"
|
||||
bindings = [
|
||||
{ name = "game", ty = "&mut crate::game::Game<'_>", value = "&mut *self" },
|
||||
{ name = "key", ty = "&str", value = "key" },
|
||||
{ name = "pressed", ty = "bool", value = "pressed" },
|
||||
]
|
||||
|
||||
[[injection]]
|
||||
id = "wheel"
|
||||
kind = "HEAD"
|
||||
target = "horizon_client::game::native_hooks::Game<'_>::native_wheel"
|
||||
helper = "wheel.rs"
|
||||
bindings = [
|
||||
{ name = "game", ty = "&crate::game::Game<'_>", value = "&*self" },
|
||||
{ name = "steps", ty = "f32", value = "steps" },
|
||||
{ name = "claimed", ty = "&mut bool", value = "&mut *claimed" },
|
||||
]
|
||||
|
||||
[[injection]]
|
||||
id = "zoom"
|
||||
kind = "HEAD"
|
||||
target = "horizon_client::game::native_hooks::Game<'_>::native_zoom"
|
||||
helper = "zoom.rs"
|
||||
bindings = [
|
||||
{ name = "game", ty = "&crate::game::Game<'_>", value = "self" },
|
||||
{ name = "zoom", ty = "&mut Option<crate::game::native_hooks::NativeZoom>", value = "&mut *zoom" },
|
||||
]
|
||||
|
||||
[[injection]]
|
||||
id = "list"
|
||||
kind = "HEAD"
|
||||
target = "horizon_client::game::native_hooks::Game<'_>::native_mods"
|
||||
helper = "list.rs"
|
||||
bindings = [
|
||||
{ name = "mods", ty = "&mut Vec<crate::game::native_hooks::NativeMod>", value = "&mut *mods" },
|
||||
]
|
||||
|
||||
[[injection]]
|
||||
id = "settings"
|
||||
kind = "HEAD"
|
||||
target = "horizon_client::game::native_hooks::Game<'_>::native_settings"
|
||||
helper = "settings.rs"
|
||||
bindings = [
|
||||
{ name = "game", ty = "&mut crate::game::Game<'_>", value = "&mut *self" },
|
||||
{ name = "mod_id", ty = "&str", value = "mod_id" },
|
||||
{ name = "buttons", ty = "&mut Vec<String>", value = "&mut *buttons" },
|
||||
]
|
||||
|
||||
[[injection]]
|
||||
id = "click"
|
||||
kind = "HEAD"
|
||||
target = "horizon_client::game::native_hooks::Game<'_>::native_settings_click"
|
||||
helper = "click.rs"
|
||||
bindings = [
|
||||
{ name = "game", ty = "&mut crate::game::Game<'_>", value = "&mut *self" },
|
||||
{ name = "mod_id", ty = "&str", value = "mod_id" },
|
||||
{ name = "button", ty = "usize", value = "button" },
|
||||
]
|
||||
@@ -0,0 +1,16 @@
|
||||
// The zoom key went down or up: the player's choice of key, kept as the
|
||||
// WebAssembly Zoom kept it.
|
||||
fn zoom_key(game: &mut crate::game::Game<'_>, key: &str, pressed: bool) {
|
||||
use crate::game::official_zoom as zoom;
|
||||
// Turned off in Options > Mods: nothing until the next build leaves it out.
|
||||
if !game.native_on(zoom::ID) {
|
||||
return;
|
||||
}
|
||||
let bound = game.native_mod_key(zoom::ID, zoom::KEY, zoom::DEFAULT_KEY);
|
||||
if key != bound {
|
||||
return;
|
||||
}
|
||||
let playing = game.native_playing();
|
||||
let game = &*game;
|
||||
zoom::key(playing, pressed, |name| game.native_setting(zoom::ID, name));
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
// Zoom's row in Options > Mods, with its key.
|
||||
fn zoom_listed(mods: &mut Vec<crate::game::native_hooks::NativeMod>) {
|
||||
use crate::game::official_zoom as zoom;
|
||||
mods.push(crate::game::native_hooks::NativeMod {
|
||||
id: zoom::ID.into(),
|
||||
title: format!("Zoom {}", zoom::VERSION),
|
||||
description: zoom::DESCRIPTION.into(),
|
||||
keys: vec![[zoom::KEY.into(), "Zoom key".into(), zoom::DEFAULT_KEY.into()]],
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
// Horizon's official Zoom, formerly the WebAssembly mod and before that a
|
||||
// built-in extra: hold the key (Z by default) to zoom in, and turn the mouse
|
||||
// wheel while holding it to zoom further in or out. Its settings are the
|
||||
// WebAssembly mod's files (`horizon-mods/.horizon-mod-data/zoom/level`,
|
||||
// `smooth`, `sensitivity`) and its key `key.zoom.zoom` in `horizon-mods.txt`,
|
||||
// so a player keeps them. This file is both the mod's Cargo library (its
|
||||
// tests) and the module the bundle adds to the game.
|
||||
use std::cell::RefCell;
|
||||
|
||||
/// The mod's id: its settings folder and its key's line.
|
||||
pub const ID: &str = "zoom";
|
||||
pub const VERSION: &str = "2.0.0";
|
||||
pub const DESCRIPTION: &str =
|
||||
"Hold a key to zoom in; the mouse wheel changes the zoom while you hold it. Horizon's own.";
|
||||
/// The key's name, and its default.
|
||||
pub const KEY: &str = "zoom";
|
||||
pub const DEFAULT_KEY: &str = "key.keyboard.z";
|
||||
/// The zooms the starting zoom cycles through, and the wheel's limits.
|
||||
pub const LEVELS: [u8; 6] = [2, 3, 4, 6, 8, 10];
|
||||
const MIN: f32 = 1.25;
|
||||
const MAX: f32 = 10.0;
|
||||
/// Each wheel step multiplies the zoom by this.
|
||||
const STEP: f32 = 1.25;
|
||||
|
||||
/// The settings, as the WebAssembly Zoom kept them.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Settings {
|
||||
/// The zoom the key starts at, one of `LEVELS`.
|
||||
pub level: u8,
|
||||
/// Ease in and out like the spyglass.
|
||||
pub smooth: bool,
|
||||
/// Turn slower the more you zoom in.
|
||||
pub sensitivity: bool,
|
||||
}
|
||||
|
||||
impl Settings {
|
||||
/// From the stored values (`get` reads one key's file); the extra's
|
||||
/// defaults (4x, smooth, slower turning) for the ones missing.
|
||||
pub fn read(get: impl Fn(&str) -> Option<String>) -> Self {
|
||||
let level = get("level")
|
||||
.and_then(|v| v.trim().parse::<u8>().ok())
|
||||
.map_or(4, |v| v.clamp(2, MAX as u8));
|
||||
let flag = |key: &str| get(key).is_none_or(|v| v.trim() != "false");
|
||||
Self {
|
||||
level,
|
||||
smooth: flag("smooth"),
|
||||
sensitivity: flag("sensitivity"),
|
||||
}
|
||||
}
|
||||
/// The settings page's buttons, as the WebAssembly Zoom labelled them.
|
||||
pub fn labels(&self) -> Vec<String> {
|
||||
let on = |b: bool| if b { "ON" } else { "OFF" };
|
||||
vec![
|
||||
format!("Starting zoom: {}x", self.level),
|
||||
format!("Smooth zoom: {}", on(self.smooth)),
|
||||
format!("Slower turning: {}", on(self.sensitivity)),
|
||||
]
|
||||
}
|
||||
/// A click on button `i` of `labels`.
|
||||
pub fn click(&mut self, i: usize) {
|
||||
match i {
|
||||
0 => {
|
||||
// As the extra cycled: an unknown level counts as the first.
|
||||
let at = LEVELS.iter().position(|&l| l == self.level).unwrap_or(0);
|
||||
self.level = LEVELS[(at + 1) % LEVELS.len()];
|
||||
}
|
||||
1 => self.smooth = !self.smooth,
|
||||
2 => self.sensitivity = !self.sensitivity,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
/// What to store, key by key.
|
||||
pub fn stored(&self) -> [(&'static str, String); 3] {
|
||||
let flag = |b: bool| if b { "true" } else { "false" }.to_owned();
|
||||
[
|
||||
("level", self.level.to_string()),
|
||||
("smooth", flag(self.smooth)),
|
||||
("sensitivity", flag(self.sensitivity)),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct State {
|
||||
/// Read on the first key press, then kept.
|
||||
settings: Option<Settings>,
|
||||
/// The zoom while the key is held; None when it is up.
|
||||
held: Option<f32>,
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
static STATE: RefCell<State> = RefCell::new(State::default());
|
||||
}
|
||||
|
||||
/// The settings, read once through `get`.
|
||||
pub fn settings(get: impl Fn(&str) -> Option<String>) -> Settings {
|
||||
STATE.with_borrow_mut(|s| *s.settings.get_or_insert_with(|| Settings::read(get)))
|
||||
}
|
||||
|
||||
/// Replaces the settings (the settings page changed them).
|
||||
pub fn set_settings(settings: Settings) {
|
||||
STATE.with_borrow_mut(|s| s.settings = Some(settings));
|
||||
}
|
||||
|
||||
/// The zoom key went down (only while playing) or up.
|
||||
pub fn key(playing: bool, pressed: bool, get: impl Fn(&str) -> Option<String>) {
|
||||
if pressed && !playing {
|
||||
return;
|
||||
}
|
||||
let level = settings(get).level;
|
||||
STATE.with_borrow_mut(|s| {
|
||||
s.held = pressed.then(|| f32::from(level).clamp(MIN, MAX));
|
||||
});
|
||||
}
|
||||
|
||||
/// Wheel steps: true when the key is held and they zoom.
|
||||
pub fn wheel(steps: f32) -> bool {
|
||||
STATE.with_borrow_mut(|s| {
|
||||
let Some(z) = &mut s.held else {
|
||||
return false;
|
||||
};
|
||||
*z = (*z * STEP.powf(steps)).clamp(MIN, MAX);
|
||||
true
|
||||
})
|
||||
}
|
||||
|
||||
/// The zoom in effect: (factor, smooth, turning scale), none when the key
|
||||
/// is up.
|
||||
pub fn view() -> Option<(f32, bool, f32)> {
|
||||
STATE.with_borrow(|s| {
|
||||
let factor = s.held?;
|
||||
let settings = s.settings?;
|
||||
let turn = if settings.sensitivity {
|
||||
1.0 / factor
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
Some((factor, settings.smooth, turn))
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn stored<'a>(pairs: &'a [(&'a str, &'a str)]) -> impl Fn(&str) -> Option<String> + 'a {
|
||||
|key| pairs.iter().find(|p| p.0 == key).map(|p| p.1.to_owned())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_the_webassembly_mods_settings() {
|
||||
assert_eq!(
|
||||
Settings::read(stored(&[])),
|
||||
Settings {
|
||||
level: 4,
|
||||
smooth: true,
|
||||
sensitivity: true
|
||||
}
|
||||
);
|
||||
let s = Settings::read(stored(&[
|
||||
("level", "8\n"),
|
||||
("smooth", "false"),
|
||||
("sensitivity", "true"),
|
||||
]));
|
||||
assert_eq!(
|
||||
s,
|
||||
Settings {
|
||||
level: 8,
|
||||
smooth: false,
|
||||
sensitivity: true
|
||||
}
|
||||
);
|
||||
assert_eq!(Settings::read(stored(&[("level", "99")])).level, 10);
|
||||
let round: Vec<(&str, String)> = s.stored().into();
|
||||
let back: Vec<(&str, &str)> = round.iter().map(|(k, v)| (*k, v.as_str())).collect();
|
||||
assert_eq!(Settings::read(stored(&back)), s);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn buttons_cycle_as_the_extra_did() {
|
||||
let mut s = Settings {
|
||||
level: 10,
|
||||
smooth: true,
|
||||
sensitivity: false,
|
||||
};
|
||||
s.click(0);
|
||||
assert_eq!(s.level, 2);
|
||||
s.level = 5;
|
||||
s.click(0);
|
||||
assert_eq!(s.level, 3);
|
||||
s.click(1);
|
||||
s.click(2);
|
||||
assert_eq!(
|
||||
s.labels(),
|
||||
[
|
||||
"Starting zoom: 3x",
|
||||
"Smooth zoom: OFF",
|
||||
"Slower turning: ON"
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn holds_zooms_with_the_wheel_and_lets_go() {
|
||||
let none = stored(&[]);
|
||||
key(false, true, &none);
|
||||
assert_eq!(view(), None, "not while a screen is open");
|
||||
assert!(!wheel(1.0));
|
||||
key(true, true, &none);
|
||||
assert_eq!(view(), Some((4.0, true, 0.25)));
|
||||
assert!(wheel(1.0));
|
||||
assert_eq!(view().map(|v| v.0), Some(5.0));
|
||||
assert!(wheel(40.0));
|
||||
assert_eq!(view().map(|v| v.0), Some(10.0));
|
||||
key(false, false, &none);
|
||||
assert_eq!(view(), None);
|
||||
assert!(!wheel(1.0));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
// Zoom's settings page: the WebAssembly Zoom's buttons.
|
||||
fn zoom_page(game: &mut crate::game::Game<'_>, mod_id: &str, buttons: &mut Vec<String>) {
|
||||
use crate::game::official_zoom as zoom;
|
||||
if mod_id != zoom::ID {
|
||||
return;
|
||||
}
|
||||
let game = &*game;
|
||||
let settings = zoom::settings(|key| game.native_setting(zoom::ID, key));
|
||||
buttons.extend(settings.labels());
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
// While the key is held, the wheel zooms instead of moving the hotbar.
|
||||
fn zoom_wheel(game: &crate::game::Game<'_>, steps: f32, claimed: &mut bool) {
|
||||
use crate::game::official_zoom as zoom;
|
||||
if game.native_on(zoom::ID) && zoom::wheel(steps) {
|
||||
*claimed = true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
// The zoom in effect, unless another mod's came first.
|
||||
fn zoom_view(game: &crate::game::Game<'_>, zoom: &mut Option<crate::game::native_hooks::NativeZoom>) {
|
||||
if zoom.is_none()
|
||||
&& game.native_on(crate::game::official_zoom::ID)
|
||||
&& let Some((factor, smooth, turn)) = crate::game::official_zoom::view()
|
||||
{
|
||||
*zoom = Some(crate::game::native_hooks::NativeZoom {
|
||||
factor,
|
||||
smooth,
|
||||
turn,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
"""Zoom in a running modded game: the WebAssembly Zoom's settings files are read and written as they
|
||||
were, the player's key choice holds, the wheel zooms instead of moving the hotbar, and the view zooms."""
|
||||
import json
|
||||
import time
|
||||
from game import Game, difference
|
||||
|
||||
DATA = "horizon-mods/.horizon-mod-data/zoom"
|
||||
# A player's settings from the WebAssembly Zoom: 8x, no smoothing, the key moved to C.
|
||||
FILES = {f"{DATA}/level": "8", f"{DATA}/smooth": "false", "horizon-mods.txt": "key.zoom.zoom:key.keyboard.c\n"}
|
||||
|
||||
|
||||
def run(ctl, resources, out):
|
||||
with Game(ctl, resources, out, FILES) as g:
|
||||
results = {}
|
||||
g.run("options", "mods")
|
||||
assert "Zoom 2.0.0: ON" in g.labels(), g.labels()
|
||||
g.run("click", g.ids()["Settings"])
|
||||
page = g.labels()
|
||||
assert page[:5] == ["Zoom key", "C", "Starting zoom: 8x", "Smooth zoom: OFF", "Slower turning: ON"], page
|
||||
g.shot("settings-page")
|
||||
g.play()
|
||||
g.run("command", "tp @s ~ ~ ~ 0 10")
|
||||
time.sleep(2)
|
||||
base = g.shot("1-normal")
|
||||
g.run("key", "z", "down")
|
||||
time.sleep(1)
|
||||
unbound = g.shot("2-z-not-bound")
|
||||
g.run("key", "z", "up")
|
||||
g.run("key", "c", "down")
|
||||
time.sleep(1)
|
||||
zoomed = g.shot("3-c-held-8x")
|
||||
slot = g.run("state")["selected"]
|
||||
g.run("scroll-at", "640", "360", "-3")
|
||||
time.sleep(1)
|
||||
results["wheel_kept_slot_while_held"] = g.run("state")["selected"] == slot
|
||||
g.run("key", "c", "up")
|
||||
time.sleep(1)
|
||||
released = g.shot("4-released")
|
||||
g.run("scroll-at", "640", "360", "-1")
|
||||
results["wheel_moves_slot_after"] = g.run("state")["selected"] != slot
|
||||
results["diff_unbound_key"] = round(difference(base, unbound), 1)
|
||||
results["diff_zoomed"] = round(difference(base, zoomed), 1)
|
||||
results["diff_released"] = round(difference(base, released), 1)
|
||||
assert results["wheel_kept_slot_while_held"] and results["wheel_moves_slot_after"], results
|
||||
assert results["diff_zoomed"] > 3 * max(results["diff_unbound_key"], results["diff_released"], 1), results
|
||||
# The page writes the WebAssembly Zoom's files.
|
||||
g.run("options", "mods")
|
||||
g.run("click", g.ids()["Settings"])
|
||||
ids = g.ids()
|
||||
g.run("click", ids["Starting zoom: 8x"])
|
||||
g.run("click", ids["Smooth zoom: OFF"])
|
||||
g.run("click", ids["C"])
|
||||
g.run("key", "x", "tap")
|
||||
stored = {k: (g.home / DATA / k).read_text() for k in ("level", "smooth", "sensitivity")}
|
||||
keys = (g.home / "horizon-mods.txt").read_text()
|
||||
results["stored"] = stored
|
||||
assert stored == {"level": "10", "smooth": "true", "sensitivity": "true"}, stored
|
||||
assert "key.zoom.zoom:key.keyboard.x" in keys, keys
|
||||
print(json.dumps({"proof": "zoom", "ok": True, **results}, indent=2))
|
||||
Reference in new issue
Block a user