From 82011f3c6fe766e1fd1a98797511bcd9e251a912 Mon Sep 17 00:00:00 2001 From: rison1337 <225131806+rison1337@users.noreply.github.com> Date: Thu, 5 Nov 2026 15:11:17 +0500 Subject: [PATCH 1/2] Allow selecting reviewed native hook fragments for stamping Keep unselected contracts pending, preserve merge order within the selection, and reject unknown names before writing. Co-Authored-By: Codex GPT-6 --- tests/test_stamp_native_hooks.py | 79 +++++++++++++++++++++++++++ tools/native-hook-fragments/README.md | 22 +++++++- tools/stamp_native_hooks.py | 14 ++++- 3 files changed, 109 insertions(+), 6 deletions(-) diff --git a/tests/test_stamp_native_hooks.py b/tests/test_stamp_native_hooks.py index 6e202b582b..3d2d9aae11 100644 --- a/tests/test_stamp_native_hooks.py +++ b/tests/test_stamp_native_hooks.py @@ -6,6 +6,7 @@ import subprocess import tempfile import unittest from pathlib import Path +from unittest.mock import patch SPEC = importlib.util.spec_from_file_location( "stamp_native_hooks", Path(__file__).resolve().parents[1] / "tools/stamp_native_hooks.py" @@ -88,6 +89,84 @@ class StampTests(unittest.TestCase): self.assertEqual(json.loads((self.root / stamp.CATALOG).read_text())["hooks"], []) self.assertEqual(json.loads((self.root / stamp.CATALOG).read_text())["helpers"], fragment["helpers"]) + def test_selected_fragments_keep_merge_order_and_others_stay_pending(self): + self.git("add", ".") + self.git("commit", "-qm", "base") + # An older, unfinished API must not stop two later, reviewed APIs being registered. + for name in ("blocked", "z", "a"): + self.fragment(name) + self.git("add", ".") + self.git("commit", "-qm", name) + blocked = self.root / stamp.FRAGMENTS / "blocked.json" + pending_comment = self.root / "pending.rs" + pending_comment.write_text("/// Since API @API:blocked@.\n") + fragment = json.loads(blocked.read_text()) + fragment["comments"] = ["pending.rs"] + blocked.write_text(json.dumps(fragment)) + api = self.root / stamp.MOD_API + api.write_text("/// APIs @API:blocked@, @API:z@ and @API:a@.\n") + before = {path: path.read_bytes() for path in (blocked, pending_comment)} + + # CLI order must not override merge order; duplicate selections must not consume numbers. + self.assertFalse(stamp.stamp(False, ["a", "z", "a"])) + source = (self.root / stamp.SOURCE).read_text() + self.assertIn("API 8 adds z.", source) + self.assertIn("API 9 adds a.", source) + self.assertIn("NATIVE_HOOKS_API: u32 = 9;", source) + self.assertEqual(api.read_text(), "/// APIs @API:blocked@, 8 and 9.\n") + docs = (self.root / stamp.DOCS).read_text() + self.assertIn("API 8 adds z; requires 8.", docs) + self.assertIn("API 9 adds a; requires 9.", docs) + self.assertEqual({path: path.read_bytes() for path in before}, before) + self.assertEqual(sorted(path.stem for path in blocked.parent.glob("*.json")), ["blocked"]) + hooks = json.loads((self.root / stamp.CATALOG).read_text())["hooks"] + self.assertEqual([h["name"] for h in hooks], ["a", "z"]) + + # Once ready, the skipped API gets the next free number, even though it merged first. + self.assertTrue(stamp.stamp(True)) + self.assertFalse(stamp.stamp(False)) + self.assertIn("API 10 adds blocked.", (self.root / stamp.SOURCE).read_text()) + self.assertIn("API 10 adds blocked; requires 10.", (self.root / stamp.DOCS).read_text()) + self.assertEqual(api.read_text(), "/// APIs 10, 8 and 9.\n") + self.assertEqual(pending_comment.read_text(), "/// Since API 10.\n") + hooks = json.loads((self.root / stamp.CATALOG).read_text())["hooks"] + self.assertEqual([h["name"] for h in hooks], ["a", "z", "blocked"]) + self.assertFalse(stamp.stamp(False)) + + def test_selection_rejects_unknown_names_before_writing_even_in_check_mode(self): + path = self.fragment("ready") + before = {p: p.read_bytes() for p in self.root.rglob("*") if p.is_file()} + for check in (False, True): + with self.subTest(check=check): + with self.assertRaisesRegex(ValueError, "unknown pending fragment.*typo"): + stamp.stamp(check, ["ready", "typo"]) + self.assertEqual({p: p.read_bytes() for p in before}, before) + path.unlink() + with self.assertRaisesRegex(ValueError, "unknown pending fragment.*ready"): + stamp.stamp(False, ["ready"]) + + def test_selection_cli_check_is_read_only_and_can_stamp_helper_only(self): + self.git("add", ".") + self.git("commit", "-qm", "base") + path = self.fragment("ready") + fragment = json.loads(path.read_text()) + fragment["hooks"] = [] + fragment["helpers"] = [{"name": "helper", "target": "target::helper"}] + path.write_text(json.dumps(fragment)) + blocked = self.fragment("blocked") + before = {p: p.read_bytes() for p in self.root.rglob("*") if p.is_file()} + with patch("sys.argv", ["stamp_native_hooks.py", "--check", "--only", "ready"]): + self.assertEqual(stamp.main(), 1) + self.assertEqual({p: p.read_bytes() for p in before}, before) + with patch("sys.argv", ["stamp_native_hooks.py", "--only", "ready"]): + self.assertEqual(stamp.main(), 0) + catalog = json.loads((self.root / stamp.CATALOG).read_text()) + self.assertEqual(catalog["hooks"], []) + self.assertEqual(catalog["helpers"], fragment["helpers"]) + self.assertIn("NATIVE_HOOKS_API: u32 = 8;", (self.root / stamp.SOURCE).read_text()) + self.assertFalse(path.exists()) + self.assertEqual(blocked.read_bytes(), before[blocked]) + def test_catalog_facts_travel_with_the_hook(self): self.git("add", ".") self.git("commit", "-qm", "base") diff --git a/tools/native-hook-fragments/README.md b/tools/native-hook-fragments/README.md index 03ea29c508..37f95431e3 100644 --- a/tools/native-hook-fragments/README.md +++ b/tools/native-hook-fragments/README.md @@ -26,7 +26,23 @@ not API yet; do not depend on it until it has a number. After hook PRs merge, run `python3 tools/stamp_native_hooks.py` on current main and open a PR with the result. The tool assigns one API number per -fragment in first-parent merge order. It consumes all pending fragments. -`--check` exits nonzero when any are pending. Running the tool again does -nothing. For local testing only, run it on a hook branch without committing +fragment in first-parent merge order. By default it consumes all pending +fragments; running it again does nothing. `--check` exits nonzero when any +are pending. + +To register reviewed fragments while unrelated contracts are still being +settled, select their names without `.json`: + +```sh +python3 tools/stamp_native_hooks.py --only gui_render_post client_services +``` + +Only the selected fragments get numbers, still in merge order regardless of +argument order. All others and their markers remain pending; a later stamp +continues from the current API number. Missing names (including already +stamped ones) fail before writing anything. `--check --only ...` checks the +selection without changing files. Selection does not check dependencies: +include any pending contracts the selected APIs require. + +For local testing only, run the tool on a hook branch without committing the result. If another stamp PR merges first, regenerate from current main. diff --git a/tools/stamp_native_hooks.py b/tools/stamp_native_hooks.py index ddbac2dd01..2b81298642 100644 --- a/tools/stamp_native_hooks.py +++ b/tools/stamp_native_hooks.py @@ -16,8 +16,14 @@ CATALOG = Path("native-hooks.json") MOD_API = Path("crates/horizon-client/src/mod_api.rs") -def stamp(check): +def stamp(check, only=None): paths = sorted((ROOT / FRAGMENTS).glob("*.json")) + if only is not None: + selected = set(only) + missing = selected - {path.stem for path in paths} + if missing: + raise ValueError(f"unknown pending fragment(s): {', '.join(sorted(missing))}") + paths = [path for path in paths if path.stem in selected] if not paths: return False if check: @@ -45,7 +51,7 @@ def stamp(check): names = {hook["name"] for hook in catalog["hooks"]} helper_names = {helper["name"] for helper in catalog.get("helpers", [])} extra = {} - # Match the build-time generator's fragment order so stamping preserves the generated API. + # Append the selected entries in filename order, as the build-time generator reads fragments. for path in sorted(ordered): fragment = json.loads(path.read_text()) for hook in fragment["hooks"]: @@ -109,9 +115,11 @@ def stamp(check): def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--check", action="store_true", help="fail if fragments are pending") + parser.add_argument("--only", nargs="+", metavar="FRAGMENT", + help="stamp only these pending fragment names (without .json), in merge order") args = parser.parse_args() try: - return int(stamp(args.check)) + return int(stamp(args.check, args.only)) except (ValueError, KeyError, OSError, subprocess.CalledProcessError) as error: print(f"stamp_native_hooks: {error}", file=sys.stderr) return 1 From 8de55d4548690462c2fa6c1d96586ffd9bc2786b Mon Sep 17 00:00:00 2001 From: rison1337 <225131806+rison1337@users.noreply.github.com> Date: Thu, 5 Nov 2026 15:11:17 +0500 Subject: [PATCH 2/2] Register merged client overlay and Host services as APIs 410 and 411 Generated with tools/stamp_native_hooks.py --only gui_render_post client_services. Leave the other 18 fragments pending while their contracts are reviewed. Co-Authored-By: Codex GPT-6 --- .../horizon-client/src/game/native_hooks.rs | 8 +- docs/native-mods.md | 4 + native-hooks.json | 106 ++++++++++++++++++ .../client_services.json | 100 ----------------- .../gui_render_post.json | 13 --- 5 files changed, 116 insertions(+), 115 deletions(-) delete mode 100644 tools/native-hook-fragments/client_services.json delete mode 100644 tools/native-hook-fragments/gui_render_post.json diff --git a/crates/horizon-client/src/game/native_hooks.rs b/crates/horizon-client/src/game/native_hooks.rs index 569fa7799a..83a9e6c44c 100644 --- a/crates/horizon-client/src/game/native_hooks.rs +++ b/crates/horizon-client/src/game/native_hooks.rs @@ -6,6 +6,10 @@ //! Horizon's own HUD extras do, and keep a mod's settings where the //! WebAssembly mod of the same id kept them. //! +//! API 411 adds client Host services, GUI painting, model-head snapshots and client lifecycle notifications. +//! +//! API 410 adds a final foreground GUI overlay (`native_gui_render_post`) and the current frame's camera during GUI rendering. +//! //! API 409 adds a client mod's play packet to the server, as it is (`native_send_packet`). //! //! API 408 adds reading the server's play packets on the client (`native_client_packet`). @@ -1160,7 +1164,7 @@ pub(super) use horizon_world::level::flags; /// The hook points' API version. The launcher reads this line from the /// source it builds; keep its form. #[allow(dead_code)] -pub(crate) const NATIVE_HOOKS_API: u32 = 409; +pub(crate) const NATIVE_HOOKS_API: u32 = 411; impl GameLevel<'_> { /// A hopper at `pos` is about to pick up `count` of `item`. Set `cancel` to leave the dropped stack in the world. Server-side. 232. @@ -2510,7 +2514,7 @@ impl Game<'_> { /// is in GUI pixels. `native_camera` is this frame's world camera (None on /// the panorama). `gui_hidden` is the F1 setting; `focused` is window focus. /// A mod decides whether its foreground indicator respects those flags. - /// Client only; the host is read-only. API @API:gui_render_post@. + /// Client only; the host is read-only. API 410. pub(super) fn native_gui_render_post( &self, batch: &mut HudBatch, diff --git a/docs/native-mods.md b/docs/native-mods.md index 0980e9ba42..50127d57ef 100644 --- a/docs/native-mods.md +++ b/docs/native-mods.md @@ -1704,6 +1704,10 @@ API 408 adds reading the server's play packets on the client (`native_client_pac API 409 adds `native_send_packet(packet)` sends any serverbound play packet (`ServerboundPlayPacket`), a plugin channel's custom payload included, to any server, unchecked; an error when not in a server's play state. Bundles need "hooks": 409. +API 410 adds `native_gui_render_post(batch, gui_hidden, focused)` draws after the complete normal GUI batch: screens, prompts, toasts, debug overlays, pad cursor and IME included. It fires during gameplay, menus and the title, even with F1; loading/file-upgrade and resource-reload overlays do not fire it. The read-only host's `camera()` is the current frame's world camera (None on the panorama), including bob/spin, FOV and projection. `batch` uses GUI pixels; `gui_hidden` reports F1 and `focused` reports window focus so the mod can choose when its indicator is visible. The existing `hud` and `screen_render_post` hooks retain their drawing order and visibility rules. Bundles need "hooks": 410. + +API 411 adds Client-only Host snapshots of the window, connection, entities, interpolated model heads and profile skins; visual rays and block occlusion facts; atlas GUI painting, nine-slice widgets and tooltips; persisted mod key changes and a local action bar. client_start runs before connecting (a window need not exist yet), client_disconnecting before a real connection is discarded (not dimension travel), client_stopping once before final save/exit. menu_background can change a normal menu blur request (None disables it; finite 1..64 only). Bundles need "hooks": 411. + ## What the launcher does **Developing a mod?** Run `horizon-launcher settings native-profile dev` once, or pick "Dev build" in the Mods page's diff --git a/native-hooks.json b/native-hooks.json index 2c1a3b75d3..453d8a80e3 100644 --- a/native-hooks.json +++ b/native-hooks.json @@ -673,6 +673,66 @@ { "name": "send_packet", "target": "horizon_client::game::native_net::Game<'_>::native_send_packet" + }, + { + "name": "client_state", + "target": "horizon_client::game::native_client_services::Game<'_>::native_client_state" + }, + { + "name": "client_entities", + "target": "horizon_client::game::native_client_services::Game<'_>::native_client_entities" + }, + { + "name": "client_heads", + "target": "horizon_client::game::native_client_services::Game<'_>::native_client_heads" + }, + { + "name": "client_profiles", + "target": "horizon_client::game::native_client_services::Game<'_>::native_client_profiles" + }, + { + "name": "client_visual_clear", + "target": "horizon_client::game::native_client_services::Game<'_>::native_client_visual_clear" + }, + { + "name": "client_block_obstruction", + "target": "horizon_client::game::native_client_services::Game<'_>::native_client_block_obstruction" + }, + { + "name": "gui_paint", + "target": "horizon_client::game::native_client_services::Game<'_>::native_gui_paint" + }, + { + "name": "gui_nine_slice", + "target": "horizon_client::game::native_client_services::Game<'_>::native_gui_nine_slice" + }, + { + "name": "gui_tooltip", + "target": "horizon_client::game::native_client_services::Game<'_>::native_gui_tooltip" + }, + { + "name": "set_mod_key", + "target": "horizon_client::game::native_client_services::Game<'_>::native_set_mod_key" + }, + { + "name": "client_action_bar", + "target": "horizon_client::game::native_client_services::Game<'_>::native_client_action_bar" + }, + { + "name": "text_width", + "target": "horizon_client::game::native_hooks::Game<'_>::native_text_width" + }, + { + "name": "playing", + "target": "horizon_client::game::native_hooks::Game<'_>::native_playing" + }, + { + "name": "window", + "target": "horizon_client::game::native_hooks::Game<'_>::native_window" + }, + { + "name": "player", + "target": "horizon_client::game::native_hooks::Game<'_>::native_player" } ], "hooks": [ @@ -4974,6 +5034,52 @@ "mod_api": { "contract": "listen" } + }, + { + "name": "client_start", + "version": 1, + "target": "horizon_client::game::native_hooks::Game<'_>::native_client_start", + "side": "client", + "mod_api": { + "contract": "listen" + } + }, + { + "name": "client_disconnecting", + "version": 1, + "target": "horizon_client::game::native_hooks::Game<'_>::native_client_disconnecting", + "side": "client", + "mod_api": { + "contract": "listen" + } + }, + { + "name": "client_stopping", + "version": 1, + "target": "horizon_client::game::native_hooks::Game<'_>::native_client_stopping", + "side": "client", + "mod_api": { + "contract": "listen" + } + }, + { + "name": "menu_background", + "version": 1, + "target": "horizon_client::game::native_hooks::Game<'_>::native_menu_background", + "side": "client", + "mod_api": { + "contract": "listen" + } + }, + { + "name": "gui_render_post", + "version": 1, + "target": "horizon_client::game::native_hooks::Game<'_>::native_gui_render_post", + "side": "client", + "cancellable": false, + "mod_api": { + "contract": "listen" + } } ] } diff --git a/tools/native-hook-fragments/client_services.json b/tools/native-hook-fragments/client_services.json deleted file mode 100644 index 31b1e9a539..0000000000 --- a/tools/native-hook-fragments/client_services.json +++ /dev/null @@ -1,100 +0,0 @@ -{ - "hooks": [ - { - "name": "client_start", - "target": "horizon_client::game::native_hooks::Game<'_>::native_client_start", - "side": "client", - "mod_api": { - "contract": "listen" - } - }, - { - "name": "client_disconnecting", - "target": "horizon_client::game::native_hooks::Game<'_>::native_client_disconnecting", - "side": "client", - "mod_api": { - "contract": "listen" - } - }, - { - "name": "client_stopping", - "target": "horizon_client::game::native_hooks::Game<'_>::native_client_stopping", - "side": "client", - "mod_api": { - "contract": "listen" - } - }, - { - "name": "menu_background", - "target": "horizon_client::game::native_hooks::Game<'_>::native_menu_background", - "side": "client", - "mod_api": { - "contract": "listen" - } - } - ], - "helpers": [ - { - "name": "client_state", - "target": "horizon_client::game::native_client_services::Game<'_>::native_client_state" - }, - { - "name": "client_entities", - "target": "horizon_client::game::native_client_services::Game<'_>::native_client_entities" - }, - { - "name": "client_heads", - "target": "horizon_client::game::native_client_services::Game<'_>::native_client_heads" - }, - { - "name": "client_profiles", - "target": "horizon_client::game::native_client_services::Game<'_>::native_client_profiles" - }, - { - "name": "client_visual_clear", - "target": "horizon_client::game::native_client_services::Game<'_>::native_client_visual_clear" - }, - { - "name": "client_block_obstruction", - "target": "horizon_client::game::native_client_services::Game<'_>::native_client_block_obstruction" - }, - { - "name": "gui_paint", - "target": "horizon_client::game::native_client_services::Game<'_>::native_gui_paint" - }, - { - "name": "gui_nine_slice", - "target": "horizon_client::game::native_client_services::Game<'_>::native_gui_nine_slice" - }, - { - "name": "gui_tooltip", - "target": "horizon_client::game::native_client_services::Game<'_>::native_gui_tooltip" - }, - { - "name": "set_mod_key", - "target": "horizon_client::game::native_client_services::Game<'_>::native_set_mod_key" - }, - { - "name": "client_action_bar", - "target": "horizon_client::game::native_client_services::Game<'_>::native_client_action_bar" - }, - { - "name": "text_width", - "target": "horizon_client::game::native_hooks::Game<'_>::native_text_width" - }, - { - "name": "playing", - "target": "horizon_client::game::native_hooks::Game<'_>::native_playing" - }, - { - "name": "window", - "target": "horizon_client::game::native_hooks::Game<'_>::native_window" - }, - { - "name": "player", - "target": "horizon_client::game::native_hooks::Game<'_>::native_player" - } - ], - "rust_summary": "client Host services, GUI painting, model-head snapshots and client lifecycle notifications.", - "doc_summary": "Client-only Host snapshots of the window, connection, entities, interpolated model heads and profile skins; visual rays and block occlusion facts; atlas GUI painting, nine-slice widgets and tooltips; persisted mod key changes and a local action bar. client_start runs before connecting (a window need not exist yet), client_disconnecting before a real connection is discarded (not dimension travel), client_stopping once before final save/exit. menu_background can change a normal menu blur request (None disables it; finite 1..64 only). Bundles need \"hooks\": @API:client_services@." -} diff --git a/tools/native-hook-fragments/gui_render_post.json b/tools/native-hook-fragments/gui_render_post.json deleted file mode 100644 index c15b102590..0000000000 --- a/tools/native-hook-fragments/gui_render_post.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "hooks": [ - { - "name": "gui_render_post", - "target": "horizon_client::game::native_hooks::Game<'_>::native_gui_render_post", - "mod_api": {"contract": "listen"}, - "side": "client", - "cancellable": false - } - ], - "rust_summary": "a final foreground GUI overlay (`native_gui_render_post`) and the current frame's camera during GUI rendering.", - "doc_summary": "`native_gui_render_post(batch, gui_hidden, focused)` draws after the complete normal GUI batch: screens, prompts, toasts, debug overlays, pad cursor and IME included. It fires during gameplay, menus and the title, even with F1; loading/file-upgrade and resource-reload overlays do not fire it. The read-only host's `camera()` is the current frame's world camera (None on the panorama), including bob/spin, FOV and projection. `batch` uses GUI pixels; `gui_hidden` reports F1 and `focused` reports window focus so the mod can choose when its indicator is visible. The existing `hud` and `screen_render_post` hooks retain their drawing order and visibility rules. Bundles need \"hooks\": @API:gui_render_post@." -}