Package as .hnmod and pin Nightly 2026-10-28

- tools/package.py writes the source-only .hnmod Horizon's registry and launcher take
  (horizon-native.json + mixins/<id>/), reproducible bytes; based on levzzz5154's Veinminer packager.
- The example's id is example_mod: registry ids are lowercase letters, digits and _.
- Pinned to Nightly 2026-10-28 (83f15fa), whose source stays downloadable as a
  nightly-source/ tag; README says how to move to a newer Nightly and publish.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Ludovic MarieandClaude Opus 5.5 committed 2026-10-28 13:40:43 +01:00
1 parent c8bfba72fb
commit cf1c281e03
7 files changed
+82 -10

No files matched your search

+1 -1
View File
@@ -8,7 +8,7 @@ description = "A minimal native Horizon Mixins mod"
[lib]
# This exact source is also injected as a same-crate module by horizon-mixins.
path = "mixins/example-mod/runtime.rs"
path = "mixins/example_mod/runtime.rs"
[workspace]
members = ["tools/audit"]
+23 -3
View File
@@ -48,7 +48,7 @@ a separate clone/worktree and set `HORIZON_MIXINS=/path/to/pinned/horizon-mixins
```text
Cargo.toml Standalone runtime library + source audit workspace
compatibility.json Exact upstream/tool revisions and source hashes
mixins/example-mod/
mixins/example_mod/
horizon-mixin.toml Module and HEAD declaration
break.rs Small typed hook adapter
runtime.rs Runtime implementation and unit tests (single source)
@@ -61,7 +61,7 @@ tools/audit/ Rust AST comparison against the pristine expor
To rename the example:
1. Change Cargo package name/description and the manifest's mod ID/version.
2. Rename `mixins/example-mod/` and update the Cargo `[lib].path`.
2. Rename `mixins/example_mod/` and update the Cargo `[lib].path`.
3. Change the manifest module name `example_mod` and its reference in `break.rs`.
4. Change the runtime log prefix, unit-test expectation and the native test filter
`game::example_mod::tests` in `tools/horizon.py`.
@@ -120,10 +120,30 @@ Horizon version**, and explicitly pass their path:
Prepare those resources using the compatible Horizon launcher or
`horizon-prepare --prepare`; game resources are not supplied by this repository.
Use the binary's `--help` for world/server arguments. Breaking a block should
print `[example-mod] Breaking block at ...` to the host's stderr. The HEAD log
print `[example_mod] Breaking block at ...` to the host's stderr. The HEAD log
reports attempts, including attempts the original game later rejects.
Compilation tests do not establish interactive gameplay/resource compatibility.
## Pin to a Nightly, package, publish
A native mod builds on one exact Horizon commit, and every mod of a player's build must pin the same one, so
pin a **Nightly**: its release notes name the commit, and the tag `nightly-source/<date>-<short>` keeps that
commit's source downloadable after the Nightly is replaced. This template pins Nightly 2026-10-28
(`83f15fa`). To move to a newer Nightly:
1. Put its commit in `compatibility.json` (`horizon_revision`) and in the manifest's
`[compatibility] horizon_revision`.
2. Update `source_sha256` for each target file (`git -C ../horizon show <commit>:<path> | sha256sum`).
3. Run the checks below. When a target moved (the game is being split into crates), `plan` names the
missing function: find its new path in that commit and fix `target` in the manifest.
Then `python3 tools/package.py` writes `target/<id>-<version>.hnmod`: `horizon-native.json` plus your
`mixins/<id>/` sources, byte-for-byte reproducible. Submit it to
[horizon-mods](https://github.com/horizon-rust/horizon-mods) as a `native-mod`. A maintainer reads the
source before it is listed, and the Horizon launcher compiles it with its own Rust on the pinned commit, in
an instance of its own. The mod id must be lowercase letters, digits and `_` (the registry's rule), and
the bundle folder is named after it.
## Validation
```sh
+2 -2
View File
@@ -1,7 +1,7 @@
{
"horizon_revision": "028f60881678a9a0e3e198f693a82858fa475c76",
"horizon_revision": "83f15fa276c44711276b6ceb5f0fe0c9c534dc66",
"source_sha256": {
"crates/horizon-client/src/game.rs": "0dfd3cb9a29a43f7faa3443eb25a88290b6a8e05b18f52c892e8d14e232a2170"
"crates/horizon-client/src/game.rs": "6d0497ae51f3f04133bbb40a7b1554ea1303c49525eb1689e758939c35859d20"
},
"horizon_repository": "https://github.com/horizon-rust/horizon",
"horizon_mixins_repository": "https://github.com/horizon-rust/horizon-mixins",
File renamed without changes.
@@ -1,10 +1,10 @@
[mod]
id = "example-mod"
id = "example_mod"
version = "0.1.0"
priority = 100
[compatibility]
horizon_revision = "028f60881678a9a0e3e198f693a82858fa475c76"
horizon_revision = "83f15fa276c44711276b6ceb5f0fe0c9c534dc66"
[[module]]
id = "runtime"
@@ -3,7 +3,7 @@
pub fn block_break_message(pos: [i32; 3]) -> String {
format!(
"[example-mod] Breaking block at {}, {}, {}",
"[example_mod] Breaking block at {}, {}, {}",
pos[0], pos[1], pos[2]
)
}
@@ -20,7 +20,7 @@ mod tests {
fn message_preserves_signed_block_coordinates() {
assert_eq!(
block_break_message([-3, 64, 12]),
"[example-mod] Breaking block at -3, 64, 12"
"[example_mod] Breaking block at -3, 64, 12"
);
}
}
+52
View File
@@ -0,0 +1,52 @@
#!/usr/bin/env python3
"""Package the mod as a source-only `.hnmod`, the file Horizon's registry and launcher take.
The archive holds `horizon-native.json` and `mixins/<id>/` (only `.rs` and `.toml`), with fixed dates and
modes, so the same sources always give the same bytes and the same SHA-256. Horizon's
`horizon_mods::index::native` reads it without running anything; the launcher compiles it on the pinned
Horizon revision. Based on levzzz5154's packager for Veinminer.
"""
import hashlib
import json
from pathlib import Path
import tomllib
import zipfile
ROOT = Path(__file__).resolve().parents[1]
GAME = '26.3'
def package():
compat = json.loads((ROOT / 'compatibility.json').read_text())
manifests = sorted((ROOT / 'mixins').glob('*/horizon-mixin.toml'))
if len(manifests) != 1:
raise SystemExit('a .hnmod holds one bundle: keep one mixins/<id>/horizon-mixin.toml')
bundle = manifests[0].parent
mixin = tomllib.loads(manifests[0].read_text())
mod = mixin['mod']
if bundle.name != mod['id']:
raise SystemExit(f"rename mixins/{bundle.name}/ to mixins/{mod['id']}/ (the registry finds it by id)")
if not mod['id'].replace('_', '').isalnum() or not mod['id'].islower():
raise SystemExit('the registry takes ids of lowercase letters, digits and _ only')
if mixin.get('compatibility', {}).get('horizon_revision') != compat['horizon_revision']:
raise SystemExit('horizon-mixin.toml and compatibility.json pin different Horizon revisions')
manifest = dict(schema=1, id=mod['id'], version=mod['version'], game=GAME,
horizon=compat['horizon_revision'], mixins=compat['horizon_mixins_revision'])
files = {'horizon-native.json': (json.dumps(manifest, indent=2, sort_keys=True) + '\n').encode()}
for path in sorted(bundle.iterdir()):
if path.is_file() and path.suffix in ('.rs', '.toml'):
files[path.relative_to(ROOT).as_posix()] = path.read_bytes()
out = ROOT / 'target' / f"{mod['id']}-{mod['version']}.hnmod"
out.parent.mkdir(parents=True, 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(dict(file=str(out), size=out.stat().st_size,
sha256=hashlib.sha256(out.read_bytes()).hexdigest()), indent=2))
if __name__ == '__main__':
package()