mirror of
https://github.com/horizon-rust/horizon-mods.git
synced 2026-11-07 11:05:58 -10:00
Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a3ac43ff5d | ||
|
|
57e676ad49 | ||
|
|
8ba9a1d9ea | ||
|
|
1b18062368 | ||
|
|
93c34ad534 | ||
|
|
d2de4c18de | ||
|
|
037298b5a7 | ||
|
|
68bd653d9c | ||
|
|
c7432500fc | ||
|
|
d0d3e68ec4 |
No files matched your search
@@ -0,0 +1,103 @@
|
||||
# Native mods on the newest Nightly, as players' launchers build them (scripts/native_compat.py):
|
||||
# - on a pull request, each new native-mod version, applied together with the most downloaded native mods
|
||||
# and compiled (`cargo check`); its support modules' names against every registry mod's;
|
||||
# - every day after the Nightly, every native mod's newest version, applied, then the rest compiled
|
||||
# (`cargo check`): the ones horizon-mixins refuses alone and the ones whose own code doesn't compile go
|
||||
# to the `native-health` release, which publish adds to the index (`broken_on`) so the launcher warns
|
||||
# before an install.
|
||||
# The check compiles the submission, whose crates may have build scripts: it runs with a token that can
|
||||
# only read, and main's script, never the pull request's.
|
||||
name: native-compat
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths: ["mods/*.toml"]
|
||||
schedule:
|
||||
- cron: "40 18 * * *"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
horizon:
|
||||
description: "Horizon commit, or `latest` (the newest Nightly)"
|
||||
default: latest
|
||||
upload:
|
||||
description: "Publish the result (native-health release); `false` for a trial run"
|
||||
default: "true"
|
||||
permissions:
|
||||
contents: read
|
||||
concurrency:
|
||||
group: native-compat-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
env:
|
||||
# The Rust the launcher builds native mods with (Horizon's launcher/src/rust_toolchain.rs).
|
||||
RUST: "1.99.1"
|
||||
jobs:
|
||||
check:
|
||||
if: github.event_name == 'pull_request'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 120
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.base.sha }}
|
||||
path: base
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
path: head
|
||||
persist-credentials: false
|
||||
- name: Does it change a native mod?
|
||||
id: native
|
||||
run: |
|
||||
if [ -n "$(python3 base/scripts/native_compat.py new base head)" ]; then
|
||||
echo "changed=true" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
- if: steps.native.outputs.changed == 'true'
|
||||
run: rustup toolchain install $RUST --profile minimal && rustup default $RUST
|
||||
- if: steps.native.outputs.changed == 'true'
|
||||
run: sudo apt-get update && sudo apt-get install -y libasound2-dev libudev-dev pkg-config
|
||||
- if: steps.native.outputs.changed == 'true'
|
||||
name: On the newest Nightly, with the popular native mods
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -o pipefail
|
||||
python3 base/scripts/native_compat.py check base head | tee -a "$GITHUB_STEP_SUMMARY"
|
||||
health:
|
||||
if: github.event_name != 'pull_request'
|
||||
runs-on: ubuntu-latest
|
||||
# Each mod horizon-mixins refuses costs one more apply of the whole set.
|
||||
timeout-minutes: 300
|
||||
permissions:
|
||||
contents: write
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
GH_REPO: ${{ github.repository }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- run: python3 -m unittest discover -s scripts -p 'test_*.py'
|
||||
- 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
|
||||
# Yesterday's dependencies, checked, and horizon-mixins: only Horizon's own crates and the mods compile again.
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry/index
|
||||
~/.cargo/registry/cache
|
||||
~/.cargo/git/db
|
||||
native/target/compat/cargo
|
||||
native/target/horizon-mixins
|
||||
key: native-health-${{ env.RUST }}-${{ github.run_id }}
|
||||
restore-keys: native-health-${{ env.RUST }}-
|
||||
- name: Every native mod's newest version on the newest Nightly
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
HORIZON: ${{ inputs.horizon || 'latest' }}
|
||||
run: |
|
||||
set -o pipefail
|
||||
python3 scripts/native_compat.py health --horizon "$HORIZON" --out native-health.json | tee -a "$GITHUB_STEP_SUMMARY"
|
||||
- name: Keep it for publish
|
||||
if: inputs.upload != 'false'
|
||||
run: |
|
||||
gh release view native-health >/dev/null 2>&1 \
|
||||
|| gh release create native-health --prerelease --title "Native mods' health" \
|
||||
--notes "native-health.json: the native mods that don't apply on the newest Nightly (native-compat.yml). publish adds them to the index as broken_on."
|
||||
gh release upload native-health native-health.json --clobber
|
||||
@@ -73,6 +73,21 @@ jobs:
|
||||
| if $n > 0 then .downloads = $n else . end)' site/index.json > index.counted.json
|
||||
mv index.counted.json site/index.json
|
||||
jq -r '.projects[] | "\(.id) \(.downloads // 0)"' site/index.json
|
||||
- name: Native mods that don't apply on the newest Nightly
|
||||
run: |
|
||||
# native-compat.yml's daily health: the version each names gets `broken_on`, the Horizon
|
||||
# commit horizon-mixins refused it on and its code, so the launcher warns before an install.
|
||||
if gh release download native-health --pattern native-health.json --dir health 2>/dev/null; then
|
||||
jq --slurpfile h health/native-health.json '
|
||||
$h[0] as $health
|
||||
| .projects |= map(. as $p | .versions |= map(
|
||||
($health.broken[$p.id] // null) as $b
|
||||
| if $b != null and $b.version == .version
|
||||
then .broken_on = [{horizon: $health.horizon, code: $b.code}] else . end))' \
|
||||
site/index.json > index.health.json
|
||||
mv index.health.json site/index.json
|
||||
jq -r '.projects[] | select(.versions[0].broken_on) | "\(.id) \(.versions[0].version): \(.versions[0].broken_on[0].code)"' site/index.json
|
||||
fi
|
||||
- name: The site
|
||||
run: |
|
||||
cp -R icons site/icons
|
||||
|
||||
@@ -11,9 +11,10 @@ with no file, network or clock access, and can only do what their page lists.
|
||||
Native source mods (`.hnmod`, kind `native-mod`) are built into an isolated game by
|
||||
a launcher with native-mod support. They run with the player’s account permissions.
|
||||
Their bundles pin the exact Horizon and Horizon Mixins revisions used for the build.
|
||||
Other revisions have not been validated. Registry checks inspect the source bundle without
|
||||
executing native code; reviewers must also examine the source and pinned-build
|
||||
validation. Publishing these requires the native-mod changes to `cargo-horizon-mod`
|
||||
Other revisions have not been validated. Registry checks inspect the source bundle and compile it
|
||||
on the newest Nightly with the most downloaded native mods (in a job whose token
|
||||
can only read) without running it; reviewers must also examine the source and
|
||||
pinned-build validation. Publishing these requires the native-mod changes to `cargo-horizon-mod`
|
||||
and the launcher to have shipped first.
|
||||
|
||||
**Who is responsible.** Except the projects marked Official (made by the Horizon project), everything here is made and
|
||||
@@ -65,6 +66,7 @@ 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-compat.yml` | Native mods on the newest Nightly, as the launcher builds them: a pull request's new version applied and compiled with the most downloaded native mods, its module names against every mod's; daily, every mod's newest version, the ones that don't apply marked `broken_on` in the index (`scripts/native_compat.py`) |
|
||||
| `.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, Coordinates HUD): the source of their `.hnmod` files |
|
||||
| `native/tools/` | Shared by them: apply on Horizon, check, build, package, prove (`native/tools/native.py`) |
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 2.0 KiB |
@@ -24,6 +24,19 @@ categories = ["utility", "food"]
|
||||
tags = ["hud", "hunger", "saturation", "food", "port"]
|
||||
key = "RWT6eNq4e5WfVDA2lzOOi6XCm/+jJgilqT1+HUUM33ie5MCjcRfSg760"
|
||||
|
||||
[[versions]]
|
||||
version = "0.1.3"
|
||||
published = "2026-11-05"
|
||||
game = "26.3"
|
||||
file = "appleskin-0.1.3.hnmod"
|
||||
sha256 = "2dac91d4c4c4ab65684f41a3279c6b27beffa7407e5362b0d7a97c28c07914a6"
|
||||
signature = """
|
||||
untrusted comment: signature from minisign secret key
|
||||
RUT6eNq4e5WfVGmizdOQdqc3jXpjW/MxBBP/JE8iFYmkrE0cKbEOUP5t11senfkM2hkDW2JLSOEJCfQJT7PMS/LnYbXET1ux0Ag=
|
||||
trusted comment: timestamp:1793891067\tfile:appleskin-0.1.3.hnmod\thashed
|
||||
r7jcpfe0pAz6c2FZAuAd6EUJojhQ56mGoq/H9hYXyC8rZ6QuwErQt/nEM4ly1VjMIKS7pbn6vCz0wK7jIak+DQ=="""
|
||||
changelog = "Fix for https://github.com/horizon-rust/horizon-mods/issues/289"
|
||||
|
||||
[[versions]]
|
||||
version = "0.1.2"
|
||||
published = "2026-11-02"
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
id = "worldmap"
|
||||
kind = "native-mod"
|
||||
name = "World Map"
|
||||
summary = "A full screen, self-writing world map: cave layers, waypoints, dimensions, teleport and PNG export."
|
||||
description = """
|
||||
A full screen map that writes itself as you explore.
|
||||
|
||||
- **Self-writing map.** The terrain you have seen stays on the map after you leave, kept per world, dimension and cave layer, in the vanilla map colours.
|
||||
- **Smooth.** Drag to move, scroll to zoom towards the pointer; far zoom blurs instead of flickering.
|
||||
- **Cave layers.** Under a roof, in a cave or in the Nether the map shows the floor round you; layered (a map for each 16 blocks of height) or full, with a Top Y slider from -64 to 319.
|
||||
- **Dimensions.** One button shows the Overworld, the Nether or the End from any of them, with the other world's coordinates of the place under the pointer.
|
||||
- **Waypoints.** Right-click for a menu: add, rename, 16 colours, three shapes, teleport (/tp), delete. Waypoints show in the world with their name and distance, a waypoint is made where you die, and a list with search (L) finds them.
|
||||
- **Also:** the biome under the pointer, other players on the map, go to coordinates (J), a chunk grid, your trail, and an export of the whole map as a PNG.
|
||||
- **Keys.** A World Map section in Options > Controls > Key Binds (with Reset), ten keys, all rebindable. Open the map with M.
|
||||
- **Russian** interface when the game is in Russian.
|
||||
|
||||
**What it does and doesn't do:** it reads the chunks the game has loaded around you and the game's own state, draws on its own screen, and keeps files in `config/worldmap/` (the explored chunks and a waypoints file for each world). It sends `/tp` in chat when you choose Teleport. It makes no network connection and needs no other mod.
|
||||
|
||||
**Native mod, pinned** to Horizon Nightly `66f3981` (Minecraft 26.3): it adds rows to the game's Key Binds screen and reuses the minimap's colour scan, so it builds on that revision."""
|
||||
authors = ["Px228-Da-Da"]
|
||||
owners = ["Px228-Da-Da"]
|
||||
owner_ids = [160890712]
|
||||
license = "MIT"
|
||||
source = "https://github.com/Px228-Da-Da/horizon-worldmap"
|
||||
icon = "icons/worldmap.png"
|
||||
categories = ["utility", "adventure"]
|
||||
tags = ["map", "worldmap", "waypoints", "navigation", "caves", "minimap"]
|
||||
screenshots = ["screenshots/worldmap/1.png", "screenshots/worldmap/2.png", "screenshots/worldmap/3.png"]
|
||||
|
||||
[[versions]]
|
||||
version = "0.5.0"
|
||||
published = "2026-11-06"
|
||||
game = "26.3"
|
||||
file = "worldmap-0.5.0.hnmod"
|
||||
sha256 = "970b1186fdcd9f7b479e4a838747489a2f286b7a7dcf5cfff76bc912a2c5c7dd"
|
||||
changelog = """
|
||||
First public release.
|
||||
|
||||
- Self-writing full screen map, kept per world, dimension and cave layer
|
||||
- Smooth drag and zoom, cave layers with a Top Y slider, dimension button
|
||||
- Waypoints (colours, shapes, teleport, death waypoint) with a searchable list
|
||||
- Biome, other players, go to coordinates, chunk grid, PNG export
|
||||
- Ten rebindable keys in Key Binds, a settings page, Russian interface"""
|
||||
@@ -117,8 +117,8 @@ the other operators on. Needs horizon#6106 (`native_inventory`, `native_move_sta
|
||||
|
||||
**Vanish.** `/vanish` takes an operator of level 3 out of the Tab list and the world of every player below level 3,
|
||||
whatever the game mode (survival and creative too), and those players don't read them joining or leaving; operators of
|
||||
level 3 or more still see them, and are told who vanished. A vanished operator reads a subtitle the whole time
|
||||
(*Vanished: only operators see you*), isn't counted online (the sidebar, the Tab list's footer), doesn't hold the night up, isn't
|
||||
level 3 or more still see them, and are told who vanished. A vanished operator reads a line above the hotbar the
|
||||
whole time (*Vanished: only operators see you*), isn't counted online (the sidebar, the Tab list's footer), doesn't hold the night up, isn't
|
||||
announced as AFK or on a first join, and can't be reached by name by those who don't see them (`/tpa`, `/trade`,
|
||||
`/coinflip`, `/playtime`). It lasts over a relog and a restart (`vanish.json`) until `/vanish` again. Needs
|
||||
horizon#7003 (`native_hide_player`).
|
||||
|
||||
@@ -2682,22 +2682,18 @@ pub(in crate::game) fn tick(game: &mut Game<'_>) {
|
||||
game.native_sleep_ignored(uuid, ignored);
|
||||
}
|
||||
}
|
||||
// A vanished player doesn't hold the night up; the subtitle lasts a
|
||||
// little past the next second's (1.5 s), so it never blinks.
|
||||
// A vanished player doesn't hold the night up. The reminder is on the
|
||||
// action bar, above the hotbar, not in the middle of the screen: sent once
|
||||
// a second, it stays up (an action bar line lasts about 3 s), so it never blinks.
|
||||
for (uuid, name) in subtitles {
|
||||
game.native_sleep_ignored(uuid, true);
|
||||
let subtitle = serde_json::json!({"text": smp_vanish::SUBTITLE, "color": "aqua"});
|
||||
for line in [
|
||||
format!("title {name} times 0 30 5"),
|
||||
format!("title {name} subtitle {subtitle}"),
|
||||
format!("title {name} title \"\""),
|
||||
] {
|
||||
game.native_run_command(&line);
|
||||
}
|
||||
let line = serde_json::json!({"text": smp_vanish::SUBTITLE, "color": "aqua"});
|
||||
game.native_run_command(&format!("title {name} actionbar {line}"));
|
||||
}
|
||||
for (uuid, name) in unvanished {
|
||||
game.native_sleep_ignored(uuid, false);
|
||||
game.native_run_command(&format!("title {name} clear"));
|
||||
// An empty line takes the reminder down at once, and leaves any title alone.
|
||||
game.native_run_command(&format!("title {name} actionbar \"\""));
|
||||
}
|
||||
for line in particles.into_iter().chain(casino) {
|
||||
game.native_run_command(&line);
|
||||
|
||||
+12
-9
@@ -1068,27 +1068,30 @@ def trades(server, alex, steve, port, ctl, resources, root):
|
||||
|
||||
|
||||
def vanish(server, alex, steve, out):
|
||||
"""/vanish: Alex (an operator) leaves Steve's world and Tab list, and reads a subtitle the whole time; Steve can't
|
||||
"""/vanish: Alex (an operator) leaves Steve's world and Tab list, and reads a line above the hotbar the whole time; Steve can't
|
||||
reach her by name; /vanish again brings her back. Steve can't vanish."""
|
||||
r = {}
|
||||
players = lambda c: [e for e in c.run("entities", 64, "player")["entities"]]
|
||||
subtitle = lambda c: c.run("messages", 1).get("subtitle") or ""
|
||||
actionbar = lambda c: c.run("messages", 1).get("actionbar") or ""
|
||||
centre = lambda c: (c.run("messages", 1).get("subtitle") or "") + (c.run("messages", 1).get("title") or "")
|
||||
r["seen_before"] = len(players(steve))
|
||||
r["refused"] = steve.say("/vanish")
|
||||
assert any("Unknown" in l or "Only operators" in l or "permission" in l for l in r["refused"]), r
|
||||
r["vanish"] = alex.say("/vanish")
|
||||
assert any("You vanished" in l for l in r["vanish"]), r
|
||||
time.sleep(2.5)
|
||||
# Over a few seconds, not just once: the subtitle never lapses.
|
||||
r["subtitles"] = []
|
||||
# Over a few seconds, not just once: the line never lapses.
|
||||
r["actionbar"] = []
|
||||
for _ in range(4):
|
||||
r["subtitles"].append(subtitle(alex))
|
||||
r["actionbar"].append(actionbar(alex))
|
||||
time.sleep(1)
|
||||
assert all(s == "Vanished: only operators see you" for s in r["subtitles"]), r
|
||||
assert "Vanished" not in subtitle(steve), r
|
||||
assert all(s == "Vanished: only operators see you" for s in r["actionbar"]), r
|
||||
# On the action bar only: nothing in the middle of the screen.
|
||||
assert "Vanished" not in centre(alex), r
|
||||
assert "Vanished" not in actionbar(steve), r
|
||||
if r["seen_before"]:
|
||||
assert not players(steve), "Steve no longer sees Alex"
|
||||
alex.run("screenshot", str(Path(out).resolve() / "vanish-subtitle.png"))
|
||||
alex.run("screenshot", str(Path(out).resolve() / "vanish-actionbar.png"))
|
||||
steve.run("key", "tab", "down")
|
||||
time.sleep(1)
|
||||
steve.run("screenshot", str(Path(out).resolve() / "vanish-tab.png"))
|
||||
@@ -1100,7 +1103,7 @@ def vanish(server, alex, steve, out):
|
||||
r["back"] = alex.say("/vanish")
|
||||
assert any("You are visible again." in l for l in r["back"]), r
|
||||
time.sleep(3)
|
||||
assert "Vanished" not in subtitle(alex), r
|
||||
assert "Vanished" not in actionbar(alex), r
|
||||
if r["seen_before"]:
|
||||
assert players(steve), "Steve sees Alex again"
|
||||
time.sleep(6) # the spam guard's window, for the steps after
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 298 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 445 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 421 KiB |
Binary file not shown.
@@ -0,0 +1,413 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Whether native mods build on the newest Nightly, together, as a player's launcher builds them.
|
||||
|
||||
python3 scripts/native_compat.py check BASE HEAD # a pull request's new native versions
|
||||
python3 scripts/native_compat.py health --out health.json # every native mod's newest version
|
||||
|
||||
`check` takes each native-mod version HEAD lists and BASE doesn't, and fails when:
|
||||
- one of its support modules has the name, in the same file, of another registry mod's (the launcher
|
||||
can't build both: E_MODULE_CONFLICT);
|
||||
- horizon-mixins doesn't apply it on the newest Nightly with the most downloaded native mods
|
||||
(`--popular`), as the launcher moves mods on the hook points to the Horizon it builds;
|
||||
- the game doesn't compile (`cargo check`) with it and those mods, when it does without it.
|
||||
Popular mods that don't apply on that Nightly are left out, as the launcher leaves them out, and listed.
|
||||
|
||||
`health` applies every native mod's newest version on the newest Nightly and writes the ones horizon-mixins
|
||||
refuses with only the mods they need; then `cargo check`s the rest together, rounds leaving out the mods the errors
|
||||
are in (scripts/native_trace.py, as the launcher traces them), and writes those too, each with its first error. An
|
||||
error of the kind two mods cause together (a name defined twice...) is checked again with that mod alone. The publish
|
||||
job adds them all to the index (`broken_on`), and the launcher warns before an install.
|
||||
|
||||
horizon-mixins only reads the bundles; `cargo check` runs a mod crate's build script, if it has one, so
|
||||
`check` runs in a job whose token can only read (native-compat.yml). Horizon's source comes from GitHub's archive.
|
||||
"""
|
||||
import argparse
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tomllib
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "native" / "tools"))
|
||||
import native # noqa: E402 (native/tools/native.py: source, latest_nightly, get, log)
|
||||
import native_trace # noqa: E402
|
||||
|
||||
INDEX = "https://horizon-rust.github.io/horizon-mods/index.json"
|
||||
REGISTRY = "horizon-rust/horizon-mods"
|
||||
MIXINS_GIT = "https://github.com/horizon-rust/horizon-mixins"
|
||||
WORK = native.TARGET / "compat"
|
||||
|
||||
|
||||
def log(*args):
|
||||
native.log(*args)
|
||||
|
||||
|
||||
class Bundle:
|
||||
"""An unpacked `.hnmod`: its mixin bundle in WORK/mods/<set>/<id>/."""
|
||||
|
||||
def __init__(self, data, origin):
|
||||
archive = zipfile.ZipFile(io.BytesIO(data))
|
||||
meta = json.loads(archive.read("horizon-native.json"))
|
||||
self.id, self.version, self.origin = meta["id"], meta["version"], origin
|
||||
self.mixins = meta.get("mixins")
|
||||
prefix = f"mixins/{self.id}/"
|
||||
self.files = {n[len(prefix):]: archive.read(n) for n in archive.namelist()
|
||||
if n.startswith(prefix) and not n.endswith("/")}
|
||||
manifest = tomllib.loads(self.files["horizon-mixin.toml"].decode())
|
||||
self.modules = {(m["file"], m["name"]) for m in manifest.get("module", [])}
|
||||
self.needs = [d for d, v in manifest.get("dependencies", {}).items()
|
||||
if not (isinstance(v, dict) and v.get("optional"))]
|
||||
# The launcher rewrites these before horizon-mixins and stages API packages for them (native_mods/inject.rs,
|
||||
# api.rs), which this script doesn't: they are applied here, not compiled.
|
||||
self.api = "api" in manifest or any(isinstance(i, dict) and "trait_method" in i
|
||||
for i in manifest.get("injection", []))
|
||||
|
||||
def unpack(self, into, revision):
|
||||
out = into / self.id
|
||||
for name, data in self.files.items():
|
||||
path = out / name
|
||||
if ".." in Path(name).parts:
|
||||
sys.exit(f"{self.id}: {name} leaves its folder")
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_bytes(data)
|
||||
# As the launcher does for a mod built on another Horizon than it pins.
|
||||
toml = out / "horizon-mixin.toml"
|
||||
text = re.sub(r'horizon_revision\s*=\s*"[0-9a-f]{40}"', f'horizon_revision = "{revision}"', toml.read_text())
|
||||
toml.write_text(text)
|
||||
|
||||
|
||||
def download(url, sha256):
|
||||
cache = WORK / "files" / sha256
|
||||
if not cache.is_file():
|
||||
data = native.get(url)
|
||||
if hashlib.sha256(data).hexdigest() != sha256:
|
||||
sys.exit(f"{url}: its SHA-256 isn't the index's")
|
||||
cache.parent.mkdir(parents=True, exist_ok=True)
|
||||
cache.write_bytes(data)
|
||||
return cache.read_bytes()
|
||||
|
||||
|
||||
def published():
|
||||
"""The newest version of every native mod in the published index, most downloaded first."""
|
||||
index = json.loads(native.get(INDEX))
|
||||
revoked = {r.get("sha256") for r in index.get("revoked", [])}
|
||||
found = []
|
||||
for project in sorted(index["projects"], key=lambda p: -p.get("downloads", 0)):
|
||||
if project.get("kind") != "native-mod":
|
||||
continue
|
||||
version = next((v for v in project["versions"] if v["file"]["sha256"] not in revoked), None)
|
||||
if version:
|
||||
found.append(Bundle(download(version["file"]["url"], version["file"]["sha256"]), "registry"))
|
||||
return found
|
||||
|
||||
|
||||
def mixins_tool(bundles):
|
||||
"""horizon-mixins as the launcher picks it: the newest of its own (Horizon main's MIXINS_REV) and the
|
||||
mods' pins, by commit date."""
|
||||
text = native.get(f"https://raw.githubusercontent.com/{native.REPO}/main/launcher/src/native_mods.rs").decode()
|
||||
revs = {re.search(r'pub const MIXINS_REV: &str = "([0-9a-f]{40})"', text).group(1)}
|
||||
revs |= {b.mixins for b in bundles if b.mixins}
|
||||
def date(rev):
|
||||
commit = json.loads(native.get(f"https://api.github.com/repos/horizon-rust/horizon-mixins/commits/{rev}"))
|
||||
return commit["commit"]["committer"]["date"]
|
||||
rev = max(revs, key=date)
|
||||
root = native.TARGET / "horizon-mixins" / rev
|
||||
binary = root / "bin" / "horizon-mixins"
|
||||
if not binary.is_file():
|
||||
log(f"Building horizon-mixins {rev[:7]}")
|
||||
subprocess.run(["cargo", "install", "--locked", "--quiet", "--git", MIXINS_GIT, "--rev", rev,
|
||||
"--root", str(root), "horizon-mixins-cli"], check=True)
|
||||
return binary
|
||||
|
||||
|
||||
def culprit(diagnostic, bundles):
|
||||
"""The bundle a horizon-mixins diagnostic names: by mod id, its manifest's path, its module's name or
|
||||
destination; between two mods' modules, the later one (the launcher's rule)."""
|
||||
by_id = {b.id: b for b in bundles}
|
||||
if diagnostic.get("mod_id") in by_id:
|
||||
return by_id[diagnostic["mod_id"]]
|
||||
file = diagnostic.get("file", "")
|
||||
for b in bundles:
|
||||
if f"/{b.id}/horizon-mixin.toml" in file:
|
||||
return b
|
||||
named = re.search(r"support module '([^']+)'", diagnostic.get("message", ""))
|
||||
declaring = [b for b in bundles if any((named and n == named.group(1)) or f == file for f, n in b.modules)]
|
||||
return declaring[-1] if declaring else None
|
||||
|
||||
|
||||
def apply(tool, tree, bundles, revision, name):
|
||||
"""horizon-mixins on `bundles` together, leaving out each one it refuses (and the ones needing it) until
|
||||
the rest applies: (stage folder or None, {id: diagnostic})."""
|
||||
left, refused = list(bundles), {}
|
||||
while left:
|
||||
mods = WORK / "mods" / name
|
||||
shutil.rmtree(mods, ignore_errors=True)
|
||||
mods.mkdir(parents=True)
|
||||
for b in left:
|
||||
b.unpack(mods, revision)
|
||||
out = WORK / "stage" / name
|
||||
shutil.rmtree(out, ignore_errors=True)
|
||||
run = subprocess.run([str(tool), "apply", "--workspace", str(tree), "--mods", str(mods), "--out", str(out)],
|
||||
capture_output=True, text=True)
|
||||
if run.returncode == 0:
|
||||
return out, refused
|
||||
text = run.stderr + run.stdout
|
||||
try:
|
||||
diagnostic = json.loads(text[text.index("{"):text.rindex("}") + 1])
|
||||
except ValueError:
|
||||
sys.exit(f"horizon-mixins failed without a diagnostic:\n{text[-2000:]}")
|
||||
bad = culprit(diagnostic, left)
|
||||
if bad is None:
|
||||
sys.exit(f"horizon-mixins refused the set, naming no mod: {json.dumps(diagnostic)}")
|
||||
log(f" {bad.id} {bad.version}: {diagnostic.get('code')}: {diagnostic.get('message')}")
|
||||
refused[bad.id] = diagnostic
|
||||
gone = {bad.id}
|
||||
while True:
|
||||
more = [b for b in left if b.id not in gone and any(n in gone for n in b.needs)]
|
||||
if not more:
|
||||
break
|
||||
for b in more:
|
||||
refused[b.id] = {"code": "E_DEPENDENCY", "message": f"needs {', '.join(b.needs)}"}
|
||||
gone.add(b.id)
|
||||
left = [b for b in left if b.id not in gone]
|
||||
return None, refused
|
||||
|
||||
|
||||
# Errors two mods' code can cause together, while each alone compiles.
|
||||
PAIR_CODES = {"E0119", "E0201", "E0252", "E0254", "E0255", "E0260", "E0428", "E0592"}
|
||||
COMPILE_ROUNDS = 6
|
||||
|
||||
|
||||
def check_errors(stage):
|
||||
"""`cargo check` of the game in `stage` as the launcher compiles it (with Horizon's `mods` feature when it has
|
||||
one): None when it compiles, else its errors (native_trace.errors). The workspace is synced into one folder
|
||||
first, so cargo's incremental state survives between rounds."""
|
||||
work = WORK / "check"
|
||||
work.mkdir(parents=True, exist_ok=True)
|
||||
subprocess.run(["rsync", "-a", "--checksum", "--delete", f"{stage}/", f"{work}/"], check=True)
|
||||
command = ["cargo", "check", "--locked", "--message-format=json", "-p", "horizon-client", "--bin", "horizon-client"]
|
||||
client = tomllib.loads((work / "crates/horizon-client/Cargo.toml").read_text())
|
||||
if "mods" in client.get("features", {}):
|
||||
command += ["--features", "horizon-client/mods"]
|
||||
env = dict(os.environ, CARGO_TARGET_DIR=str(WORK / "cargo"))
|
||||
run = subprocess.run(command, cwd=work, env=env, capture_output=True, text=True)
|
||||
if run.returncode == 0:
|
||||
return None
|
||||
found = native_trace.errors(run.stdout)
|
||||
for e in found[:10]:
|
||||
log(e["rendered"].rstrip())
|
||||
return found or [{"code": "E_CARGO", "message": run.stderr[-2000:], "rendered": run.stderr[-2000:], "spans": []}]
|
||||
|
||||
|
||||
def compiles(stage):
|
||||
return stage is not None and check_errors(stage) is None
|
||||
|
||||
|
||||
def traced(errors, tree, mods):
|
||||
"""{id: its first error} for the errors in `mods`' own code (native_trace)."""
|
||||
tracer = native_trace.Tracer(WORK / "check", tree, {(b.id, b.version) for b in mods})
|
||||
found = {}
|
||||
for e in errors:
|
||||
for span in e["spans"]:
|
||||
owner = tracer.owner(span)
|
||||
if owner and owner not in found:
|
||||
where = f" ({span['file_name']}:{span.get('line_start')})"
|
||||
found[owner] = {"code": e["code"], "message": e["message"] + where}
|
||||
return found
|
||||
|
||||
|
||||
def broken_compiling(tool, tree, mods, revision, needed):
|
||||
"""The mods of `mods` (all applying) whose own code doesn't compile on `revision`: {id: error}."""
|
||||
by_id = {b.id: b for b in mods}
|
||||
left, broken = list(mods), {}
|
||||
for _ in range(COMPILE_ROUNDS):
|
||||
stage, _ = apply(tool, tree, left, revision, "health-compile")
|
||||
if stage is None:
|
||||
break
|
||||
errors = check_errors(stage)
|
||||
if errors is None:
|
||||
log(f" {len(left)} native mods compile together")
|
||||
return broken
|
||||
found = traced(errors, tree, left)
|
||||
if not found:
|
||||
log(" the errors are in no mod's code: stopping the compile check here")
|
||||
break
|
||||
for i, error in sorted(found.items()):
|
||||
if error["code"] in PAIR_CODES:
|
||||
alone = [by_id[n] for n in sorted(needed(by_id[i], set())) if n in by_id] + [by_id[i]]
|
||||
stage, _ = apply(tool, tree, alone, revision, f"health-alone-{i}")
|
||||
if stage is None or i not in traced(check_errors(stage) or [], tree, alone):
|
||||
log(f" {i}: {error['code']} only next to other mods: not broken")
|
||||
continue
|
||||
log(f" {i} {by_id[i].version}: {error['code']}: {error['message']}")
|
||||
broken[i] = error
|
||||
gone = set(found)
|
||||
while True:
|
||||
more = {b.id for b in left if b.id not in gone and any(n in gone for n in b.needs)}
|
||||
if not more:
|
||||
break
|
||||
for i in more:
|
||||
if any(n in broken for n in by_id[i].needs):
|
||||
broken[i] = {"code": "E_DEPENDENCY", "message": f"needs {', '.join(by_id[i].needs)}"}
|
||||
gone |= more
|
||||
left = [b for b in left if b.id not in gone]
|
||||
return broken
|
||||
|
||||
|
||||
def new_entries(base, head):
|
||||
"""(id, version, file, sha256) of each native-mod version HEAD lists and BASE doesn't."""
|
||||
def versions(root):
|
||||
out = {}
|
||||
for path in sorted((Path(root) / "mods").glob("*.toml")):
|
||||
entry = tomllib.loads(path.read_text())
|
||||
if entry.get("kind") == "native-mod":
|
||||
for v in entry.get("versions", []):
|
||||
out[(entry["id"], v["version"])] = (v["file"], v["sha256"])
|
||||
return out
|
||||
before = versions(base)
|
||||
return [(i, v, f, s) for (i, v), (f, s) in versions(head).items() if (i, v) not in before]
|
||||
|
||||
|
||||
def new_versions(base, head):
|
||||
"""The native-mod versions HEAD lists that BASE doesn't: their file from its release once published, else
|
||||
from the `staging` prerelease the submission form uploads it to (scripts/fetch.sh)."""
|
||||
found = []
|
||||
for id, version, file, sha in new_entries(base, head):
|
||||
releases = f"https://github.com/{REGISTRY}/releases/download"
|
||||
try:
|
||||
data = download(f"{releases}/{id}-{version}/{file}", sha)
|
||||
except Exception:
|
||||
data = download(f"{releases}/staging/{sha}.hnmod", sha)
|
||||
found.append(Bundle(data, "submitted"))
|
||||
return found
|
||||
|
||||
|
||||
def check(args):
|
||||
submitted = new_versions(args.base, args.head)
|
||||
if not submitted:
|
||||
print("No new native mod version.")
|
||||
return 0
|
||||
registry = published()
|
||||
revision = native.latest_nightly() if args.horizon == "latest" else args.horizon
|
||||
tree = native.source(revision)
|
||||
failed = []
|
||||
for mod in submitted:
|
||||
others = [b for b in registry if b.id != mod.id]
|
||||
print(f"## {mod.id} {mod.version} on Horizon {revision[:7]} (the newest Nightly)")
|
||||
clashes = sorted({(b.id, f, n) for b in others for f, n in mod.modules & b.modules})
|
||||
for other, file, name in clashes:
|
||||
print(f"- support module `{name}` in `{file}` is also {other}'s: the launcher can't build both."
|
||||
f" Name it after the mod (`{mod.id}_…`).")
|
||||
popular = others[:args.popular]
|
||||
tool = mixins_tool([mod, *popular])
|
||||
stage, refused = apply(tool, tree, [mod, *popular], revision, f"{mod.id}-together")
|
||||
for other, diagnostic in refused.items():
|
||||
if other != mod.id:
|
||||
print(f"- {other} (popular) doesn't apply on this Nightly either: {diagnostic.get('code')}."
|
||||
" The launcher leaves it out.")
|
||||
if mod.id in refused:
|
||||
d = refused[mod.id]
|
||||
print(f"- **doesn't apply**: `{d.get('code')}`: {d.get('message')}"
|
||||
+ (f" (injection `{d['injection_id']}`)" if d.get("injection_id") else ""))
|
||||
failed.append(mod.id)
|
||||
continue
|
||||
if clashes:
|
||||
failed.append(mod.id)
|
||||
if args.no_compile:
|
||||
continue
|
||||
if compiles(stage):
|
||||
print(f"- applies and compiles with {len(popular) - len(refused)} popular mods.")
|
||||
continue
|
||||
alone, _ = apply(tool, tree, [mod], revision, f"{mod.id}-alone")
|
||||
without, _ = apply(tool, tree, [b for b in popular if b.id not in refused], revision, f"{mod.id}-without")
|
||||
if not compiles(alone):
|
||||
print("- **doesn't compile** on this Nightly, even alone (the errors are in the log).")
|
||||
failed.append(mod.id)
|
||||
elif without is None or compiles(without):
|
||||
print("- **doesn't compile together with the popular mods**, while each side compiles alone.")
|
||||
failed.append(mod.id)
|
||||
else:
|
||||
print("- compiles alone; the popular mods already don't compile together on this Nightly.")
|
||||
return 1 if failed else 0
|
||||
|
||||
|
||||
def health(args):
|
||||
registry = published()
|
||||
revision = native.latest_nightly() if args.horizon == "latest" else args.horizon
|
||||
tree = native.source(revision)
|
||||
tool = mixins_tool([])
|
||||
_, together = apply(tool, tree, registry, revision, "health")
|
||||
# A mod refused only next to another (both overwrite one function, both name one module) builds for a
|
||||
# player without that other one: broken on this Horizon is what horizon-mixins refuses with only the mods
|
||||
# it needs.
|
||||
by_id = {b.id: b for b in registry}
|
||||
def needed(bundle, seen):
|
||||
for n in bundle.needs:
|
||||
if n in by_id and n not in seen:
|
||||
seen.add(n)
|
||||
needed(by_id[n], seen)
|
||||
return seen
|
||||
refused = {}
|
||||
for i in sorted(together):
|
||||
mods = [by_id[n] for n in sorted(needed(by_id[i], set()))] + [by_id[i]]
|
||||
_, alone = apply(tool, tree, mods, revision, f"health-{i}")
|
||||
if i in alone:
|
||||
refused[i] = alone[i]
|
||||
else:
|
||||
log(f" {i} applies without the others: not broken")
|
||||
# The rest, compiled; without the mods the launcher prepares differently (Bundle.api) and those needing them.
|
||||
skipped = {b.id for b in registry if b.api}
|
||||
while more := {b.id for b in registry if b.id not in skipped and any(n in skipped for n in b.needs)}:
|
||||
skipped |= more
|
||||
if skipped:
|
||||
log(f" not compiled here (an API the launcher prepares): {', '.join(sorted(skipped))}")
|
||||
compiling = [b for b in registry if b.id not in refused and b.id not in skipped]
|
||||
failed = broken_compiling(tool, tree, compiling, revision, needed) if args.compile else {}
|
||||
broken = {i: dict(d, stage="apply") for i, d in refused.items()}
|
||||
broken |= {i: dict(d, stage="compile") for i, d in failed.items()}
|
||||
report = {
|
||||
"horizon": revision,
|
||||
"mixins": tool.parent.parent.name,
|
||||
"broken": {i: {"version": by_id[i].version, "code": d.get("code"), "message": d.get("message"),
|
||||
"stage": d["stage"]}
|
||||
for i, d in sorted(broken.items())},
|
||||
}
|
||||
Path(args.out).write_text(json.dumps(report, indent=2) + "\n")
|
||||
print(f"{len(broken)} of {len(registry)} native mods don't build on Horizon {revision[:7]}: "
|
||||
f"{len(refused)} don't apply ({', '.join(sorted(refused))}), {len(failed)} don't compile "
|
||||
f"({', '.join(sorted(failed))}).")
|
||||
return 0
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
sub = parser.add_subparsers(dest="action", required=True)
|
||||
c = sub.add_parser("check")
|
||||
c.add_argument("base")
|
||||
c.add_argument("head")
|
||||
c.add_argument("--popular", type=int, default=20)
|
||||
c.add_argument("--no-compile", action="store_true")
|
||||
n = sub.add_parser("new", help="list the new native-mod versions, one per line")
|
||||
n.add_argument("base")
|
||||
n.add_argument("head")
|
||||
h = sub.add_parser("health")
|
||||
h.add_argument("--out", required=True)
|
||||
h.add_argument("--no-compile", dest="compile", action="store_false")
|
||||
for p in (c, h):
|
||||
p.add_argument("--horizon", default="latest", help="a Horizon commit, or `latest` (the newest Nightly)")
|
||||
args = parser.parse_args()
|
||||
if args.action == "new":
|
||||
for id, version, _, _ in new_entries(args.base, args.head):
|
||||
print(id, version)
|
||||
return
|
||||
sys.exit(check(args) if args.action == "check" else health(args))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,191 @@
|
||||
"""Which native mod a compiler error is in, as the launcher traces it (launcher/src/native_mods/diagnostics.rs).
|
||||
|
||||
An error is a mod's when its primary span lies inside what that mod brought into the staged game: one of its
|
||||
helpers (the function of the helper's name, absent from the pristine source) or one of its support modules (the
|
||||
inline module of its name, absent from the pristine source). An error that more than one mod's code holds, or none,
|
||||
names no mod.
|
||||
"""
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
PLAN = ".horizon-mixins-stage.json"
|
||||
|
||||
|
||||
def masked(src):
|
||||
"""`src` (bytes) with comments, strings and char literals blanked, so braces and keywords are code's."""
|
||||
out = bytearray(src)
|
||||
i, n = 0, len(src)
|
||||
|
||||
def blank(a, b):
|
||||
for k in range(a, min(b, n)):
|
||||
if out[k] != ord("\n"):
|
||||
out[k] = ord(" ")
|
||||
|
||||
while i < n:
|
||||
c = src[i]
|
||||
if src.startswith(b"//", i):
|
||||
end = src.find(b"\n", i)
|
||||
end = n if end < 0 else end
|
||||
blank(i, end)
|
||||
i = end
|
||||
elif src.startswith(b"/*", i):
|
||||
depth, k = 1, i + 2
|
||||
while k < n and depth:
|
||||
if src.startswith(b"/*", k):
|
||||
depth, k = depth + 1, k + 2
|
||||
elif src.startswith(b"*/", k):
|
||||
depth, k = depth - 1, k + 2
|
||||
else:
|
||||
k += 1
|
||||
blank(i, k)
|
||||
i = k
|
||||
elif c in b"rb" and (i == 0 or not (chr(src[i - 1]).isalnum() or src[i - 1] == ord("_"))) and \
|
||||
re.match(rb"(?:br|r)(#*)\"", src[i:i + 260]):
|
||||
hashes = re.match(rb"(?:br|r)(#*)\"", src[i:i + 260])
|
||||
close = b'"' + hashes.group(1)
|
||||
end = src.find(close, i + hashes.end())
|
||||
end = n if end < 0 else end + len(close)
|
||||
blank(i, end)
|
||||
i = end
|
||||
elif c == ord('"'):
|
||||
k = i + 1
|
||||
while k < n and src[k] != ord('"'):
|
||||
k += 2 if src[k] == ord("\\") else 1
|
||||
blank(i, k + 1)
|
||||
i = k + 1
|
||||
elif c == ord("'"):
|
||||
# A char literal ('x', '\n', '\u{1F600}'), else a lifetime ('a).
|
||||
if i + 1 < n and src[i + 1] == ord("\\"):
|
||||
end = src.find(b"'", i + 2)
|
||||
end = n if end < 0 else end + 1
|
||||
blank(i, end)
|
||||
i = end
|
||||
elif i + 2 < n and src[i + 2] == ord("'"):
|
||||
blank(i, i + 3)
|
||||
i += 3
|
||||
else:
|
||||
m = re.match(rb"'([^\x00-\x7f]+)'", src[i:i + 8])
|
||||
if m:
|
||||
blank(i, i + m.end())
|
||||
i += m.end()
|
||||
else:
|
||||
i += 1
|
||||
else:
|
||||
i += 1
|
||||
return bytes(out)
|
||||
|
||||
|
||||
def item_ranges(code, keyword, name):
|
||||
"""Byte ranges of the `fn NAME` or `mod NAME { … }` items in masked source `code`, through their closing brace."""
|
||||
pattern = rb"\b%s\s+%s\b" % (keyword.encode(), re.escape(name.encode()))
|
||||
ranges = []
|
||||
for m in re.finditer(pattern, code):
|
||||
# The body's `{`, unless a `;` ends the item first; either outside the signature's () and [] (`[f64; 3]`).
|
||||
open_at, nested, k = -1, 0, m.end()
|
||||
while k < len(code):
|
||||
c = code[k]
|
||||
if c in b"([":
|
||||
nested += 1
|
||||
elif c in b")]":
|
||||
nested -= 1
|
||||
elif nested <= 0 and c in b"{;":
|
||||
open_at = k if c == ord("{") else -1
|
||||
break
|
||||
k += 1
|
||||
if open_at < 0:
|
||||
continue # a declaration without a body
|
||||
if keyword == "mod" and code[m.end():open_at].strip():
|
||||
continue
|
||||
depth, k = 0, open_at
|
||||
while k < len(code):
|
||||
if code[k] == ord("{"):
|
||||
depth += 1
|
||||
elif code[k] == ord("}"):
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
ranges.append(_with_call(code, name, m.start(), k + 1) if keyword == "fn" else (m.start(), k + 1))
|
||||
break
|
||||
k += 1
|
||||
return ranges
|
||||
|
||||
|
||||
def _with_call(code, name, start, end):
|
||||
"""The block horizon-mixins wraps a helper in, `{ fn NAME(…) { … } NAME(…) }`, when that is all the block holds:
|
||||
an error in the generated call (an argument the helper's signature no longer takes) is the helper's too."""
|
||||
before = re.search(rb"\{\s*(?:#\[[^\]]*\]\s*)*\Z", code[max(0, start - 400):start])
|
||||
after = re.match(rb"\s*%s\s*\([^{}]*\)\s*;?\s*\}" % re.escape(name.encode()), code[end:])
|
||||
if before and after:
|
||||
return start - (len(before.group(0))), end + after.end()
|
||||
return start, end
|
||||
|
||||
|
||||
def helper_name(source):
|
||||
m = re.search(r"\bfn\s+([A-Za-z_][A-Za-z0-9_]*)", source or "")
|
||||
return m.group(1) if m else None
|
||||
|
||||
|
||||
class Tracer:
|
||||
"""Traces errors in the staged workspace `stage` (horizon-mixins' output, holding its plan) whose pristine source
|
||||
is `pristine`, to the mods in `mods` ({(id, version)})."""
|
||||
|
||||
def __init__(self, stage, pristine, mods):
|
||||
self.stage, self.pristine = Path(stage).resolve(), Path(pristine)
|
||||
plan = json.loads((self.stage / PLAN).read_text())
|
||||
self.entries = [] # (id, version, file, keyword, name)
|
||||
for e in plan.get("injections", []):
|
||||
name = helper_name(e.get("helper_source"))
|
||||
if name:
|
||||
self.entries.append((e.get("mod_id"), e.get("mod_version"), e.get("file"), "fn", name))
|
||||
for e in plan.get("modules", []):
|
||||
self.entries.append((e.get("mod_id"), e.get("mod_version"), e.get("file"), "mod", e.get("name")))
|
||||
self.entries = [e for e in self.entries if (e[0], e[1]) in mods and e[2] and e[4]]
|
||||
self.cache, self.code = {}, {}
|
||||
|
||||
def _masked(self, path):
|
||||
if path not in self.code:
|
||||
self.code[path] = masked(path.read_bytes()) if path.is_file() else b""
|
||||
return self.code[path]
|
||||
|
||||
def _ranges(self, rel, keyword, name):
|
||||
key = (rel, keyword, name)
|
||||
if key not in self.cache:
|
||||
found = []
|
||||
if (self.stage / rel).is_file() and not item_ranges(self._masked(self.pristine / rel), keyword, name):
|
||||
found = item_ranges(self._masked(self.stage / rel), keyword, name)
|
||||
self.cache[key] = found
|
||||
return self.cache[key]
|
||||
|
||||
def owner(self, span):
|
||||
"""The mod (id) whose code holds a cargo JSON span, or None."""
|
||||
try:
|
||||
path = (self.stage / span["file_name"]).resolve()
|
||||
rel = path.relative_to(self.stage).as_posix()
|
||||
except (KeyError, ValueError):
|
||||
return None
|
||||
start, end = span.get("byte_start", 0), span.get("byte_end", 0)
|
||||
owners = {i for i, _, file, keyword, name in self.entries
|
||||
if file == rel and any(a <= start and end <= b for a, b in self._ranges(rel, keyword, name))}
|
||||
return owners.pop() if len(owners) == 1 else None
|
||||
|
||||
|
||||
def errors(cargo_stdout):
|
||||
"""The errors in `cargo --message-format=json` output: {code, message, rendered, spans (primary)}."""
|
||||
found = []
|
||||
for line in cargo_stdout.splitlines():
|
||||
try:
|
||||
value = json.loads(line)
|
||||
except ValueError:
|
||||
continue
|
||||
if value.get("reason") != "compiler-message":
|
||||
continue
|
||||
message = value.get("message") or {}
|
||||
if message.get("level") != "error":
|
||||
continue
|
||||
found.append({
|
||||
"code": (message.get("code") or {}).get("code") or "E_COMPILE",
|
||||
"message": message.get("message", ""),
|
||||
"rendered": message.get("rendered") or "",
|
||||
"spans": [s for s in message.get("spans", []) if s.get("is_primary")],
|
||||
})
|
||||
return found
|
||||
@@ -0,0 +1,110 @@
|
||||
"""python3 -m unittest discover -s scripts -p 'test_*.py'"""
|
||||
import json
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from native_trace import Tracer, errors, item_ranges, masked
|
||||
|
||||
PRISTINE = """struct MeshTables { base: u8, layers: u8 }
|
||||
pub fn resolve(x: u8) -> u8 {
|
||||
x
|
||||
}
|
||||
"""
|
||||
# One mod's helper inside a target (formatted with a trailing comma, an array type's `;` in its signature), another's
|
||||
# support module; braces in strings, chars and comments must not end them early.
|
||||
STAGED = """struct MeshTables { base: u8, layers: u8 }
|
||||
pub fn resolve(x: u8) -> u8 {
|
||||
if let Some(v) = {
|
||||
fn fast_mesh_resolve(x: u8, _at: [f64; 3],) -> Option<u8> {
|
||||
let _ = "}"; let _ = '}'; // }
|
||||
let _ = r#"}"#;
|
||||
Some(MeshTables { base: x }.base)
|
||||
}
|
||||
fast_mesh_resolve(x, [0.0; 3])
|
||||
} {
|
||||
return v;
|
||||
}
|
||||
x
|
||||
}
|
||||
mod carry_tables {
|
||||
pub fn lift<'a>(t: &'a super::MeshTables) -> u8 { t.missing }
|
||||
}
|
||||
fn untouched() -> u8 { MeshTables { base: 1 }.base }
|
||||
"""
|
||||
|
||||
|
||||
def span(text, needle, file="src/main.rs"):
|
||||
start = STAGED.encode().index(needle.encode())
|
||||
return {"file_name": file, "byte_start": start, "byte_end": start + len(needle), "is_primary": True}
|
||||
|
||||
|
||||
class Trace(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.dir = Path(tempfile.mkdtemp())
|
||||
(self.dir / "stage/src").mkdir(parents=True)
|
||||
(self.dir / "pristine/src").mkdir(parents=True)
|
||||
(self.dir / "stage/src/main.rs").write_text(STAGED)
|
||||
(self.dir / "pristine/src/main.rs").write_text(PRISTINE)
|
||||
self.plan([
|
||||
{"mod_id": "fast_mesh", "mod_version": "0.1.0", "file": "src/main.rs",
|
||||
"helper_source": "fn fast_mesh_resolve(x: u8) -> Option<u8> { Some(MeshTables { base: x }.base) }"},
|
||||
], [{"mod_id": "carryblock", "mod_version": "0.1.2", "file": "src/main.rs", "name": "carry_tables"}])
|
||||
self.mods = {("fast_mesh", "0.1.0"), ("carryblock", "0.1.2")}
|
||||
|
||||
def plan(self, injections, modules):
|
||||
(self.dir / "stage/.horizon-mixins-stage.json").write_text(
|
||||
json.dumps({"schema": 6, "injections": injections, "modules": modules}))
|
||||
|
||||
def tracer(self, mods=None):
|
||||
return Tracer(self.dir / "stage", self.dir / "pristine", mods or self.mods)
|
||||
|
||||
def test_an_error_in_a_helper_names_its_mod_though_formatting_changed_it(self):
|
||||
self.assertEqual(self.tracer().owner(span(STAGED, "MeshTables { base: x }")), "fast_mesh")
|
||||
|
||||
def test_an_error_in_a_support_module_names_its_mod(self):
|
||||
self.assertEqual(self.tracer().owner(span(STAGED, "t.missing")), "carryblock")
|
||||
|
||||
def test_an_error_in_the_generated_call_to_a_helper_names_its_mod(self):
|
||||
self.assertEqual(self.tracer().owner(span(STAGED, "[0.0; 3]")), "fast_mesh")
|
||||
|
||||
def test_an_error_in_horizons_own_code_names_no_mod(self):
|
||||
self.assertIsNone(self.tracer().owner(span(STAGED, "MeshTables { base: 1 }")))
|
||||
self.assertIsNone(self.tracer().owner(span(STAGED, "return v")))
|
||||
|
||||
def test_a_path_through_another_crate_folder_resolves(self):
|
||||
(self.dir / "stage/lib").mkdir()
|
||||
s = span(STAGED, "t.missing", file="lib/../src/main.rs")
|
||||
self.assertEqual(self.tracer().owner(s), "carryblock")
|
||||
|
||||
def test_a_function_the_pristine_source_has_is_not_a_mods(self):
|
||||
(self.dir / "pristine/src/main.rs").write_text(PRISTINE + "fn fast_mesh_resolve() {}\n")
|
||||
self.assertIsNone(self.tracer().owner(span(STAGED, "MeshTables { base: x }")))
|
||||
|
||||
def test_a_mod_not_built_or_two_owners_name_no_mod(self):
|
||||
self.assertIsNone(self.tracer({("carryblock", "0.1.2")}).owner(span(STAGED, "MeshTables { base: x }")))
|
||||
self.plan([{"mod_id": m, "mod_version": "1", "file": "src/main.rs",
|
||||
"helper_source": "fn fast_mesh_resolve() {}"} for m in ("a", "b")], [])
|
||||
self.assertIsNone(self.tracer({("a", "1"), ("b", "1")}).owner(span(STAGED, "MeshTables { base: x }")))
|
||||
|
||||
def test_braces_in_literals_and_comments_are_not_code(self):
|
||||
code = masked(STAGED.encode())
|
||||
(start, end), = item_ranges(code, "fn", "fast_mesh_resolve")
|
||||
self.assertTrue(STAGED.encode()[start:end].endswith(b"}.base)\n }\n fast_mesh_resolve(x, [0.0; 3])\n }"))
|
||||
self.assertEqual(len(code), len(STAGED.encode()))
|
||||
|
||||
def test_cargo_json_errors(self):
|
||||
out = "\n".join([
|
||||
"not json",
|
||||
json.dumps({"reason": "compiler-artifact"}),
|
||||
json.dumps({"reason": "compiler-message", "message": {"level": "warning", "message": "w", "spans": []}}),
|
||||
json.dumps({"reason": "compiler-message", "message": {
|
||||
"level": "error", "message": "missing fields", "code": {"code": "E0063"}, "rendered": "error[E0063]",
|
||||
"spans": [{"file_name": "a.rs", "is_primary": True}, {"file_name": "b.rs", "is_primary": False}]}}),
|
||||
])
|
||||
(only,) = errors(out)
|
||||
self.assertEqual((only["code"], [s["file_name"] for s in only["spans"]]), ("E0063", ["a.rs"]))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in new issue
Block a user