From ee78b85fd01dc0501a8b049de5007c1ddaa194dd Mon Sep 17 00:00:00 2001 From: soul Date: Wed, 21 Oct 2026 20:26:02 +0200 Subject: [PATCH 1/5] hull_source: delete the nose-ward muzzle rule; gun facing is HullData's The Blender scene cannot know which way a hardpoint points: HullData's hardpoint rotation owns that (ruling aetheria-release:ruling:gun-facing-is-hulldata). Deletes the forward list and its term in the muzzle check, the two facing tests, and the smoke's nose-ward require. Pairing stays whole. Co-Authored-By: Claude Opus 5.5 --- tools/blender/aetheria_ships/hull_source.py | 8 +++----- tools/blender/tests/smoke_hull_source.py | 1 - tools/blender/tests/test_hull_source.py | 17 ++--------------- 3 files changed, 5 insertions(+), 21 deletions(-) diff --git a/tools/blender/aetheria_ships/hull_source.py b/tools/blender/aetheria_ships/hull_source.py index e513a7454..50a932e0d 100644 --- a/tools/blender/aetheria_ships/hull_source.py +++ b/tools/blender/aetheria_ships/hull_source.py @@ -344,13 +344,11 @@ def _checks(hull, own, judge): by_id = {a.id: a for a in hull.anchors} unmuzzled = [a.id for a in hull.anchors if a.role == "weapon-mount" and not any(m.role == "weapon-muzzle" and m.parent == a.id for m in hull.anchors)] - forward = [m.id for m in hull.anchors if m.role == "weapon-muzzle" and m.parent in by_id - and by_id[m.parent].role == "weapon-mount" and m.position[1] >= by_id[m.parent].position[1]] orphans = [m.id for m in hull.anchors if m.role == "weapon-muzzle" and (m.parent not in by_id or by_id[m.parent].role != "weapon-mount")] - checks.append(Check("every weapon-mount has a muzzle nose-ward of it and no muzzle is orphaned", - not (unmuzzled or forward or orphans), - f"{len(unmuzzled)} without a muzzle, {len(forward)} not nose-ward, {len(orphans)} orphaned")) + checks.append(Check("every weapon-mount has a muzzle and no muzzle is orphaned", + not (unmuzzled or orphans), + f"{len(unmuzzled)} without a muzzle, {len(orphans)} orphaned")) return checks diff --git a/tools/blender/tests/smoke_hull_source.py b/tools/blender/tests/smoke_hull_source.py index 41ef836a6..c544339ad 100644 --- a/tools/blender/tests/smoke_hull_source.py +++ b/tools/blender/tests/smoke_hull_source.py @@ -215,7 +215,6 @@ def one_hull(scene, number, fixture, ship_id, extent, length, rows, body, palett for muzzle in roles["weapon-muzzle"]: mount = first[muzzle].parent require(mount.get("aetheria.role") == "weapon-mount" and first[muzzle].get("aetheria.order") == 1, "A muzzle is not ordered under its mount") - require(first[muzzle].matrix_world.translation.y < mount.matrix_world.translation.y, "A muzzle is not nose-ward of its mount") require(layout_rows(scene) == rows_before, "Build changed the layout buffer") check_poses(collection, root, runner) if fixture == "zh-ammo-tender": diff --git a/tools/blender/tests/test_hull_source.py b/tools/blender/tests/test_hull_source.py index c1045dba0..2a1f737f4 100644 --- a/tools/blender/tests/test_hull_source.py +++ b/tools/blender/tests/test_hull_source.py @@ -57,7 +57,7 @@ class Fixture: def __init__(self): self.library = {"zhestokost": language()} self.hull_lang = None - # Body 6 wide, 10 long, 2 high (h/w 0.33, w/l 0.6); a bilateral plate on top; one gun with a muzzle nose-ward. + # Body 6 wide, 10 long, 2 high (h/w 0.33, w/l 0.6); a bilateral plate on top; one gun with a muzzle. self.parts = { "brief": ("", brief()), "primary": (box(-3, 3, -5, 5, 0, 2), ""), @@ -221,20 +221,7 @@ class JudgementTests(unittest.TestCase): fixture.parts["brief"] = ("", brief(length=length)) self.assertEqual(fixture.failed(), ["the length lies inside the brief's range"], length) - MUZZLE = "every weapon-mount has a muzzle nose-ward of it and no muzzle is orphaned" - - def test_a_muzzle_aft_of_its_mount_is_refused(self): - fixture = Fixture() - fixture.parts["anchors"] = ("", echo("anchor", ["Gun.A", "weapon-mount", [0, 0, 3], 0, ""]) - + echo("anchor", ["Gun.A.muzzle", "weapon-muzzle", [0, 1, 3], 0, "Gun.A"]) - + echo("anchor", ["Th.A", "thruster-emitter", [1, 5, 1], 180, ""])) - self.assertEqual(fixture.failed(), [self.MUZZLE]) - - def test_a_muzzle_level_with_its_mount_is_not_nose_ward(self): - fixture = Fixture() - fixture.parts["anchors"] = ("", echo("anchor", ["Gun.A", "weapon-mount", [0, 0, 3], 0, ""]) - + echo("anchor", ["Gun.A.muzzle", "weapon-muzzle", [1, 0, 3], 0, "Gun.A"])) - self.assertEqual(fixture.failed(), [self.MUZZLE]) + MUZZLE = "every weapon-mount has a muzzle and no muzzle is orphaned" def test_a_mount_without_a_muzzle_and_a_muzzle_without_a_mount_are_refused(self): fixture = Fixture() From 6f12283dc01d46a9dfbcfe7e8dab11f56a23e71e Mon Sep 17 00:00:00 2001 From: soul Date: Wed, 21 Oct 2026 20:26:15 +0200 Subject: [PATCH 2/5] gravity map: laned-belts mapped and admitted; lane host and half-width cells filled Co-Authored-By: Claude Opus 5.5 --- docs/aetheria-gravity-map.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/aetheria-gravity-map.md b/docs/aetheria-gravity-map.md index d87657cbd..b95ddec91 100644 --- a/docs/aetheria-gravity-map.md +++ b/docs/aetheria-gravity-map.md @@ -131,11 +131,11 @@ Cells not filled, plainly: whether a per-body multiplier on c or the core is eve | **Rock** (`Asteroid`) | (belt key, index) is the `ChunkId`. The index order is fixed at generation and never re-sorted, because wear (`ChunkWear`) is keyed by it. | Persisted (Distance, Phase, Size, RotationSpeed) about the belt's parent; wear and respawn live on Zone. | The generator draws a rock. Zone owns its wear and its pose (`AsteroidBelt.Pose`). | | **Period rule** | A property of the belt, not of the rock: the persisted flag `AsteroidBeltData.RidesRail` (MessagePack key 11, JSON `ridesRail`). False: each rock reads its own distance (`PeriodAround(parent, rock.Distance)`), an annulus. True: every rock takes its rail's period (`Orbits[rail].Period`), so a Trojan swarm keeps its shape (ruling `trojans-ride-host-period-with-stations`; on own-distance rails it shears within an hour, B60). | Persisted at generation. A belt saved before key 11 reads false, the annulus it was (CultCache defaults a missing slot, B61). | Zone's constructor alone writes `AsteroidBelt.Periods`, through PeriodAround or the rail's own Period; the flag only says which. Forbidden: a period formula in Pose, BeltTargets, DrawAhead, the renderer or the generator, and a second flag or shape enum read anywhere else. | | **Extent rule** | What a belt's rail adds to its parent's L_F: `AsteroidBelt.Extent(railDistance)`, the outermost rock for an annulus, the rail's own Distance for a swarm, whose rocks' periods never read their own distance. So a swarm moves no well (B61). An edge (Oort-like) belt must add nothing beyond the window, or the family's fade band narrows (B60). | Derived at zone build. | `AsteroidBelt.Extent`, read by Zone (the only WellNode builder in play) and by the station tests' rebuilt tree. The generator's clearance tree never sees a swarm, which is not a GeneratorPlanet. | -| **Belt host** | A generator-time relation only. A Trojan host is a planet or gas giant (PlanetMass < mass <= SunMass) whose parent is a body, at a distance > 0. A ring member is never a host: its rail's pull adds its partners' (WellTree.PullAround), so a rail copied beside it would take another period and shear (B61). The swarm's rail copies the host's (Parent, Distance, Phase); that copy is all Zone needs, and nothing persists the host. A lane's host waits on laned-belts. | Lives for one GenerateZone call. | ZoneGenerator.GenerateZone's swarm pass. | +| **Belt host** | A generator-time relation only. A Trojan host is a planet or gas giant (PlanetMass < mass <= SunMass) whose parent is a body, at a distance > 0. A ring member is never a host: its rail's pull adds its partners' (WellTree.PullAround), so a rail copied beside it would take another period and shear (B61). The swarm's rail copies the host's (Parent, Distance, Phase); that copy is all Zone needs, and nothing persists the host. A lane's host is a body that keeps its slot with room on both sides of the lane (cut laned-belts: half the eligible bodies, never a Trojan host, one shape per parent). | Lives for one GenerateZone call. | ZoneGenerator.GenerateZone's swarm pass. | | **Lagrange station orbit** | A bodyless OrbitData on a host's rail, 1/6 turn ahead of or behind it (CreateLagrangeOrbit). On a swarm host it sits inside the swarm, a mining outpost among the rocks (ruling `trojans-ride-host-period-with-stations`); rocks are on the floater layer below it, so nothing collides. | Created at generation and persisted. | ZoneGenerator's station placement, unchanged: candidacy reads the GeneratorPlanet tree, where no swarm exists, so no swarm can bar its host. | | **Belt generation settings** | `ZoneGenerationSettings` in Settings.asset (an LFS object): BeltProbability 0.25, BeltMassCeiling 500, AsteroidBeltWidth (a ceiling that never binds, B56), AsteroidCount = sqrt(mass x D) + 11. Trojans: TrojanProbability 0.15 per host, TrojanArc 0.07 turn per swarm, TrojanHalfWidth 0.06 D, TrojanCount = 0.04 D + 8 rocks round each of L4 and L5 (tuning defaults for a flight, B61). The code default of TrojanProbability is 0, so settings built in code draw no swarm. | Authored. | Settings.asset. Forbidden: a tunable shape's numbers as constants in code. The swarm's random stream salt is a ZoneGenerator constant, not a tunable. | -Cells not filled, plainly: the lane's host set and half-width (laned-belts' map proposes the visual margin, ruling `lanes-two-sided-visual-margin`); where an edge cloud's rocks sit between a root's window and the zone boundary (edge-material's map, rulings `edge-material-root-only` and `edge-material-in-the-dark`). The edge material's place in the potential is not a question: it stays out of the extent, because the law breaks otherwise (B60). +Cells not filled, plainly: where an edge cloud's rocks sit between a root's window and the zone boundary (edge-material's map, rulings `edge-material-root-only` and `edge-material-in-the-dark`). The edge material's place in the potential is not a question: it stays out of the extent, because the law breaks otherwise (B60). ## Rationale @@ -180,5 +180,5 @@ Mapped and admitted (2026-10-14, base 11b2d7ad): `aetheria-gravity:cut_spec:cut- - **grid-noise-presentation**: the Sector Waves noise (after grid-lowering on layer 10, drawn only into the nebula patch height) and fine-grid detail move to non-slope presentation (grid-gravity-only-ruled); it also owns the Main Menu's own Surface Displacement brushes (B29, follow-up `menu-grid-brushes`). Waits on grid-lowering. - **top-speed-field**: top speed becomes a hull field; no drag below it, drag on the excess above it; the VelocityLimit clamp (Behaviors/VelocityLimit.cs) and always-on hull drag (Ship.cs:127-129) go; AI pilots brake actively; open: drag shape above the cap, consumables as modifiers, whether loot and wreckage come to rest (aetheria-gravity:ruling:no-drag-below-top-speed, target invariant orbits-do-not-decay). Waits on potential-owner and orbits-from-field. - **belt-shapes** (follow-up `belt-shapes`, ruling `belt-shapes-direction`; B60, B61; order by ruling `trojans-first`). One cut per shape. trojan-swarms is mapped and admitted (2026-10-18, base 67517f40): `aetheria-gravity:cut_spec:cut-trojan-swarms.r1`. - - **laned-belts**: only a body with room on both sides of its lane keeps a laned belt, and the lane's half-width is its own visual margin, not the placement Roche margin (ruling `lanes-two-sided-visual-margin`; 468 eligible at the Roche width, B60). Folds in `belt-after-belt-edge-pin`, in the same Place loop. + - **laned-belts** (mapped and admitted 2026-10-21, base d52b50f9: `aetheria-gravity:cut_spec:cut-laned-belts.r1`; lane half-width 5 body radii, a flight default): only a body with room on both sides of its lane keeps a laned belt, and the lane's half-width is its own visual margin, not the placement Roche margin (ruling `lanes-two-sided-visual-margin`; 468 eligible at the Roche width, B60). Folds in `belt-after-belt-edge-pin`, in the same Place loop. - **edge-material**: one sparse Oort-like cloud per root (per subzone root in a multi-root zone), from its family's window out toward the zone boundary, nothing at planets' rims (ruling `edge-material-root-only`); kept out of the extent (B60) and skipped by the populated radius, so its rocks sit in the dark fade (ruling `edge-material-in-the-dark`). From 51e82624d5c4de502b7955827eb16166fef23bcd Mon Sep 17 00:00:00 2001 From: soul Date: Wed, 21 Oct 2026 20:26:17 +0200 Subject: [PATCH 3/5] test_hull_source: pin that a muzzle facing any way passes the hull source Muzzles aft of and beside their mounts pass every check, so a reintroduced facing judgement fails a test. Co-Authored-By: Claude Opus 5.5 --- tools/blender/tests/test_hull_source.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/tools/blender/tests/test_hull_source.py b/tools/blender/tests/test_hull_source.py index 2a1f737f4..4cfcaf2e8 100644 --- a/tools/blender/tests/test_hull_source.py +++ b/tools/blender/tests/test_hull_source.py @@ -223,6 +223,16 @@ class JudgementTests(unittest.TestCase): MUZZLE = "every weapon-mount has a muzzle and no muzzle is orphaned" + def test_a_muzzle_facing_any_way_is_accepted(self): + # Which way a gun points is HullData's hardpoint rotation; the hull source never judges it. + fixture = Fixture() + fixture.parts["anchors"] = ("", echo("anchor", ["Gun.A", "weapon-mount", [0, 0, 3], 0, ""]) + + echo("anchor", ["Gun.A.muzzle", "weapon-muzzle", [0, 1, 3], 0, "Gun.A"]) + + echo("anchor", ["Gun.B", "weapon-mount", [0, 0, 3], 0, ""]) + + echo("anchor", ["Gun.B.muzzle", "weapon-muzzle", [1, 0, 3], 0, "Gun.B"]) + + echo("anchor", ["Th.A", "thruster-emitter", [1, 5, 1], 180, ""])) + self.assertEqual(fixture.failed(), []) + def test_a_mount_without_a_muzzle_and_a_muzzle_without_a_mount_are_refused(self): fixture = Fixture() fixture.parts["anchors"] = ("", echo("anchor", ["Gun.A", "weapon-mount", [0, 0, 3], 0, ""])) From bd5d27c52b0106d678f4723eca7229ecc66b4ebf Mon Sep 17 00:00:00 2001 From: soul Date: Wed, 21 Oct 2026 20:31:47 +0200 Subject: [PATCH 4/5] test_hull_source: a mount is not paired by another gun's muzzle Kills the surviving and->or mutant in the kept pairing rule's unmuzzled list. Co-Authored-By: Claude Opus 5.5 --- tools/blender/tests/test_hull_source.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tools/blender/tests/test_hull_source.py b/tools/blender/tests/test_hull_source.py index 4cfcaf2e8..362664de7 100644 --- a/tools/blender/tests/test_hull_source.py +++ b/tools/blender/tests/test_hull_source.py @@ -239,6 +239,11 @@ class JudgementTests(unittest.TestCase): self.assertEqual(fixture.failed(), [self.MUZZLE]) fixture.parts["anchors"] = ("", echo("anchor", ["Gun.A.muzzle", "weapon-muzzle", [0, -3, 3], 0, "Gun.A"])) self.assertEqual(fixture.failed(), [self.MUZZLE]) + # Another gun's muzzle does not pair this one. + fixture.parts["anchors"] = ("", echo("anchor", ["Gun.A", "weapon-mount", [0, 0, 3], 0, ""]) + + echo("anchor", ["Gun.A.muzzle", "weapon-muzzle", [0, -3, 3], 0, "Gun.A"]) + + echo("anchor", ["Gun.B", "weapon-mount", [2, 0, 3], 0, ""])) + self.assertEqual(fixture.failed(), [self.MUZZLE]) def test_a_muzzle_whose_parent_is_not_a_weapon_mount_is_refused(self): fixture = Fixture() From aea0ce823d5877511ca60c613d3709881bc7cd26 Mon Sep 17 00:00:00 2001 From: soul Date: Wed, 21 Oct 2026 20:37:59 +0200 Subject: [PATCH 5/5] release map: AH-P1 checks line no longer lists the deleted nose-ward rule Co-Authored-By: Claude Opus 5.5 --- docs/aetheria-release-map.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/aetheria-release-map.md b/docs/aetheria-release-map.md index 88267df37..848ab38f5 100644 --- a/docs/aetheria-release-map.md +++ b/docs/aetheria-release-map.md @@ -11238,7 +11238,7 @@ PIDs are in `S\out\pids.log`, the first 3404 and the last 20348. It then writes - **AH-P1. Checks.** All pass (`S\out\\checks.json`). - Tender: 3468 triangles. The bbox is 10.5 x 23.4 x 5.9 m, inside its 18-28 m size class. Body h/w is 0.42 and w/l is 0.61, inside Zhestokost's ranges [0.25, 0.6] and [0.35, 0.75]. Bilateral symmetry has a worst mirror miss of - 0.0000 m. It has five mount anchors, and every weapon has a muzzle nose-ward of its mount. + 0.0000 m. It has five mount anchors, and every weapon mount has its muzzle (gun facing is HullData's, ruling gun-facing-is-hulldata). - Fighter: 1466 triangles and 15 m long. The donor body's h/w is 0.20 and w/l 0.69, inside the R&D ranges. The asymmetry rule holds. - The vertex-based asymmetry figure (0.91 unmirrored) is too lenient, because tessellation alone breaks vertex