Keep superclass air refill for NoAI Nautilus

Skip only the Nautilus custom air handler under NoAI, preserving the existing Breath/drown path and normal custom behavior. Cover actual Game and full Mob ticks against fresh Java.

Co-Authored-By: Codex <[email protected]>
This commit is contained in:
Florian ERNSTandCodex committed 2026-11-06 07:59:32 +01:00
1 parent 87c51ab9c9
commit 3443abbde5
4 files changed
+380 -2

No files matched your search

+77 -1
View File
@@ -1197,9 +1197,11 @@ fn base_tick(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
m.damage(1.0, Damage::of(DamageKind::InWall));
}
// Air supply and drowning (WaterAnimal.handleAirSupply for aquatic mobs).
// Nautilus.baseTick: NoAI skips only the custom handler; the
// superclass Breath/drown path must still process its air.
if m.species() == Species::Axolotl {
axolotl_air_supply(m, w);
} else if m.kind.flags.needs_water {
} else if m.kind.flags.needs_water && !(m.no_ai && m.species() == Species::Nautilus) {
if !m.fluids.in_water {
m.air -= 1;
if m.air == -20 {
@@ -5053,3 +5055,77 @@ mod flight_effect_fall_tests {
assert!(ran, "prepared resources must run the integration fixture");
}
}
#[cfg(test)]
mod nautilus_noai_air_tests {
use super::*;
use std::sync::Arc;
fn check(water: bool, no_ai: bool, initial: i32, expected: [i32; 2]) {
let build = |world: &mut crate::world::World,
registry: &horizon_world::registry::BlockRegistry| {
world
.chunks
.insert((0, 0), Arc::new(crate::chunk::Chunk::empty(0, 0)));
if water {
let state = registry.default_state("minecraft:water").unwrap();
for x in 0..=4 {
for z in 0..=4 {
for y in 0..=8 {
world.set(x, y, z, state).unwrap();
}
}
}
}
};
let ran = crate::mobs::world::with_built_test_world(&[], build, |w, types| {
let kind = Arc::new(types.types["minecraft:nautilus"].clone());
assert!(kind.flags.needs_water && kind.flags.breathes_water);
let mut mob = Mob::new(1, kind, Vec3::new(2.5, 3.0, 2.5), 0.0);
mob.no_ai = no_ai;
mob.no_gravity = true;
mob.persistent = true;
mob.attrs.speed = 0.0;
mob.health = 10.0;
mob.air = initial;
let mut random = LegacyRandom::new(0);
let mut events = Vec::new();
for (index, expected) in expected.into_iter().enumerate() {
assert!(tick(&mut mob, w, &mut random, &mut events));
assert_eq!(mob.age, (index + 1) as u32);
assert_eq!(mob.fluids.eye_in_water, water);
assert_eq!(mob.health, 10.0);
assert_eq!(mob.no_ai, no_ai);
assert_eq!(
mob.air,
expected,
"water={water}, NoAI={no_ai}, initial={initial}, tick={}",
index + 1
);
}
});
if !ran {
eprintln!("Prepared mob data unavailable; Nautilus full-tick coverage did not run");
}
}
/// `Nautilus.baseTick`: NoAI skips only handleAirSupply; super.baseTick
/// still refills a breathing mob by four, capped at300.
#[test]
fn noai_nautilus_keeps_superclass_refill_in_both_media() {
for water in [false, true] {
check(water, true, 300, [300, 300]);
check(water, true, 100, [104, 108]);
}
}
/// With AI, handleAirSupply still uses the pre-super air on land and
/// writes300 in water. The NoAI exception must not alter that policy.
#[test]
fn normal_nautilus_keeps_its_custom_handler() {
check(false, false, 300, [299, 298]);
check(false, false, 100, [99, 98]);
check(true, false, 300, [300, 300]);
check(true, false, 100, [300, 300]);
}
}
+154
View File
@@ -0,0 +1,154 @@
# NoAI Nautilus superclass air
Pinned 26.3 `Nautilus.baseTick` records the old air, calls the superclass, and
invokes its custom `handleAirSupply` only with AI enabled on the server. The
custom handler uses that pre-super air on land, and writes 300 in water. With
NoAI the inherited breathing still refills by four, capped at 300; air must not
be frozen or forced to the maximum. Bytecode and the port ledger were inspected
independently.
The fix excludes only `Species::Nautilus` under NoAI from the existing
`needs_water` branch. It reaches the unchanged generic Breath/drown body,
including the current native hooks. Other species and the normal custom-handler
body stay unchanged. The existing normal-handler drown/dry_out damage-kind gap
is retained in the ledger; this is not a whole Nautilus handler port.
## Frozen actual Game/Java proof
`tools/test_noai_nautilus_air_parity.py` uses the existing command harness,
actual Game and fresh pinned Java. Both binaries are built on frozen source
`87c51ab9c9368700223726f03f3d5689f145bb10` with identical declared scripts and
fresh reference bytes. No pending H072/H224 production is imported.
Eight actual UUID-bound, Health 10, persistent Nautilus cover NoAI 0/1 × land/water
× loaded Short 300/100. NoGravity and a real zero movement-speed attribute keep
actors stationary without disabling normal AI. The actual owner, typed actors,
Health/NoAI/Pos, loaded floor and medium, fresh result/success sentinels and
exact two world ticks are guarded. The prepared official type data gives eye
height 0.2751 and height 0.95: both body and eye at Y=201 lie inside the queried
201 cell, with an additional 202 source-cell control. The full unit verifies
actual eye-fluid state from its real world.
Fresh Java independently validates the following Air values at cumulative 0/1/2:
| AI | Medium | Initial 300 | Initial 100 |
|---|---|---|---|
| disabled | land | 300/300/300 | 100/104/108 |
| disabled | water | 300/300/300 | 100/104/108 |
| enabled | land | 300/299/298 | 100/99/98 |
| enabled | water | 300/300/300 | 100/300/300 |
The predeclared 278-record canonical contract retains complete raw air feedback,
styles, callback/store values, Short-typed predicates, all actor/mode/media/load
and relative-clock controls. All 24 complete actor bodies and two absolute startup
clock replies remain executable trace diagnostics in the 304-record full program.
These categories were declared before the first captures. No tolerance, record
mutation or comparison mask is used.
Program SHA256:
- canonical: `778193efd641e930748fe2a9411f41f9066fd6e1df1ed60a8a9af651f418fc42`
- full: `3d19326d3734898a3ce75742021f49c2f947b9f8ac70724ed80bb706e0303573`
All scripts, references, raw replies and bodies, worlds, bytecode, binary hashes,
source checkpoints and manifests are retained locally in `.local/h071`. Unknown
phase times stay null. No game files or coordinator payloads are committed.
## Reachable full-tick regression
`cargo test -p horizon-client-lib --lib nautilus_noai_air_tests` calls the real
full `mobs::ai::tick` in the existing prepared-resource world factory, with real
water or air. It guards the actual Nautilus flags, tick age, eye immersion,
Health and NoAI, and checks the independently measured air values. Old production
has one passing normal-handler test and one failing NoAI test (land 300→299,
expected 300). This is not a copied timer model or a direct reset-helper call.
Prepared data is required; its absence is reported, not counted as runtime
coverage. The own old failure establishes that this local fixture actually ran.
The normal custom-handler body, caps and damage cadence are unchanged. No
Zombie Nautilus, other aquatic species, damage-policy, effect, lifetime, packet,
rendering or general AI closure is claimed.
```sh
python3 tools/test_noai_nautilus_air_parity.py --binary target/fast/horizon-client --work-dir .local/noai-nautilus-air
python3 tools/test_noai_nautilus_air_parity.py --binary target/fast/horizon-client --work-dir .local/noai-nautilus-air-full --full-diagnostic
```
## Paired results and full residual table
| View | Unchanged | Fixed |
|---|---|---|
| Canonical | 260/278 | 278/278 |
| Full diagnostic | 260/304 | 278/304 |
| Actual full-tick tests | 1/2 | 2/2 |
No 304/304 claim. Every fixed full-program residual is listed below. CD denotes
`Brain.memories."minecraft:attack_target_cooldown".value` (Int), not air.
| Label | Expected Java | Actual Game |
|---|---|---|
| clock-start | `[[true, 301]]` | `[[true, 0]]` |
| air_full-0-body | `CD absent` | `CD 0` |
| air_low-0-body | `CD absent` | `CD 0` |
| water_full-0-body | `CD absent` | `CD 0` |
| water_low-0-body | `CD absent` | `CD 0` |
| normal_air_full-0-body | `CD absent` | `CD 0` |
| normal_air_low-0-body | `CD absent` | `CD 0` |
| normal_water_full-0-body | `CD absent` | `CD 0` |
| normal_water_low-0-body | `CD absent` | `CD 0` |
| air_full-1-body | `CD absent` | `CD 0` |
| air_low-1-body | `CD absent` | `CD 0` |
| water_full-1-body | `CD absent` | `CD 0` |
| water_low-1-body | `CD absent` | `CD 0` |
| normal_air_full-1-body | `CD absent` | `CD 0` |
| normal_air_low-1-body | `CD absent` | `CD 0` |
| normal_water_full-1-body | `CD 2816; Rotation: [0.0f, 0.0f]` | `CD 2466; Rotation: [350.0f, 0.0f]` |
| normal_water_low-1-body | `CD 2754; Rotation: [0.0f, 0.0f]` | `CD 3114; Rotation: [350.0f, 0.0f]` |
| air_full-2-body | `CD absent` | `CD 0` |
| air_low-2-body | `CD absent` | `CD 0` |
| water_full-2-body | `CD absent` | `CD 0` |
| water_low-2-body | `CD absent` | `CD 0` |
| normal_air_full-2-body | `CD absent` | `CD 0` |
| normal_air_low-2-body | `CD absent` | `CD 0` |
| normal_water_full-2-body | `CD 2815; Rotation: [0.0f, 0.0f]` | `CD 2465; Rotation: [340.0f, 0.12813261f]` |
| normal_water_low-2-body | `CD 2753; Rotation: [0.0f, 0.0f]` | `CD 3113; Rotation: [340.0f, 0.12813261f]` |
| clock-end | `[[true, 303]]` | `[[true, 2]]` |
All body callbacks remain `[true,1]`. Complete raw strings/styles and Brain
values are retained without modification. These pre-existing Brain projection
and normal-water pose/cooldown observations are outside H071, without a claim
about their full cause or random equality. Both relative clock deltas are
exactly two; raw absolute values remain intact. No Brain, pose or clock repair
is included.
## Required checks and scope limits
`tools/pr_ready.py --quick --base 87c51ab9c9368700223726f03f3d5689f145bb10`
ran once on the final Rust/harness patch:1980 Rust passes,65 ignored and one
pre-existing `entities::drop_motion_tests::drops_match_vanillas_motion_bit_for_bit`
failure. At yaw37.5/pitch-21.25, Z bits are4597448471913462162 versus expected
4597448471913462163. Our own untouched a171 executable already reproduces that
exact failure. The drop-motion function/test and jmath source are unchanged;
the later entities.rs delta is unrelated ArmorStand first-tick state. The prior
log/provenance is retained without claiming a new H071 baseline execution.
Python reports698 tests, four known installer/shell failures and one skip:
the installer signature fixture lacks `horizon-macos-arm64.zip`, and three
optional-archive cases encounter Bash3.2 empty-array unbound variables. No
installer or drop-motion repair is included; the full self-check is not green.
Formatting, Clippy with warnings denied, doctests and public-tree checks pass;
Cargo.lock is unchanged. The ledger check reports the existing callee advisories
and a long-uncited test-fixture helper, with no new gaps. The normal Nautilus
drown/dry_out gap remains explicitly open. Assigned Game parity ran separately
from quick mode's broad-harness skip. Only evidence notes were added afterward;
Rust and harness files are unchanged.
The later98af9399c source checkpoint has no air-source delta; an isolated
armor-stand harness filter does not change this fixture's selected group.
The actual Game pair remains frozen to87c51ab9c, without a latest-base replay
claim. Resource plans retained a5GiB hard guard and combined-link headroom,
jobs2/incremental0 and one heavy build. Only verified inactive OWN artifacts
were archived, preserving original paths and per-file hashes. Unknown phase
times stay null. H072 and H224 remain independent, with no pending production
imported and no combined-runtime claim.
+1 -1
View File
@@ -26,7 +26,7 @@ Nautilus.getAmbientSound port crates/horizon-client/src/mobs/nautilus.rs::sounds
Nautilus.getHurtSound port crates/horizon-client/src/mobs/nautilus.rs::sounds by age and by under water or on land
Nautilus.getDeathSound port crates/horizon-client/src/mobs/nautilus.rs::sounds by age and by under water or on land
Nautilus.playEatingSound port crates/horizon-client/src/mobs/nautilus.rs::eat_sound baby and adult eat sounds
Nautilus.baseTick inline crates/horizon-client/src/mobs/ai.rs::base_tick gap: minor: dries out on land through the generic needs_water air supply, so the damage is the drown kind instead of dry_out and a no-AI nautilus dries out too
Nautilus.baseTick inline crates/horizon-client/src/mobs/ai.rs::base_tick NoAI skips custom supply and uses superclass Breath/drown; gap: minor: the normal handler still uses generic needs_water air so damage is drown instead of dry_out
NautilusAi.initCoreActivity inline crates/horizon-client/src/mobs/nautilus.rs::register panic 1.6 is a goal; the look and move sinks are the generic controls and the cooldown counters tick in ai_step
NautilusAi.initIdleActivity inline crates/horizon-client/src/mobs/nautilus.rs::register gap: minor: breed 0.4, tempt 1.3, swim stroll 1.0 and the target pick (find_target), but SetWalkTargetFromLookTarget is not ported so it does not swim toward what it looks at
NautilusAi.initFightActivity inline crates/horizon-client/src/mobs/nautilus.rs::register the Charge goal: speed 0.6, 12 block range, 80 tick cooldown, only without love or temptation
1 AbstractNautilus.createAttributes skip:data attributes come from the entity type table (MobType in mobs.rs)
26 Nautilus.getHurtSound port crates/horizon-client/src/mobs/nautilus.rs::sounds by age and by under water or on land
27 Nautilus.getDeathSound port crates/horizon-client/src/mobs/nautilus.rs::sounds by age and by under water or on land
28 Nautilus.playEatingSound port crates/horizon-client/src/mobs/nautilus.rs::eat_sound baby and adult eat sounds
29 Nautilus.baseTick inline crates/horizon-client/src/mobs/ai.rs::base_tick gap: minor: dries out on land through the generic needs_water air supply, so the damage is the drown kind instead of dry_out and a no-AI nautilus dries out too NoAI skips custom supply and uses superclass Breath/drown; gap: minor: the normal handler still uses generic needs_water air so damage is drown instead of dry_out
30 NautilusAi.initCoreActivity inline crates/horizon-client/src/mobs/nautilus.rs::register panic 1.6 is a goal; the look and move sinks are the generic controls and the cooldown counters tick in ai_step
31 NautilusAi.initIdleActivity inline crates/horizon-client/src/mobs/nautilus.rs::register gap: minor: breed 0.4, tempt 1.3, swim stroll 1.0 and the target pick (find_target), but SetWalkTargetFromLookTarget is not ported so it does not swim toward what it looks at
32 NautilusAi.initFightActivity inline crates/horizon-client/src/mobs/nautilus.rs::register the Charge goal: speed 0.6, 12 block range, 80 tick cooldown, only without love or temptation
+148
View File
@@ -0,0 +1,148 @@
#!/usr/bin/env python3
"""NoAI Nautilus keeps superclass air, skipping only its custom handler.
Complete scalar/action records stay strict. Full bodies and absolute clocks are
predeclared executable diagnostics; the actual two-tick delta remains strict.
"""
import hashlib
import json
from pathlib import Path
import sys
import test_command_exec_parity as driver
def subjects():
return [
(1, 'air_full', False, True, 300, [2.5, 201.0, 2.5]),
(2, 'air_low', False, True, 100, [6.5, 201.0, 2.5]),
(3, 'water_full', True, True, 300, [10.5, 201.0, 10.5]),
(4, 'water_low', True, True, 100, [13.5, 201.0, 10.5]),
(5, 'normal_air_full', False, False, 300, [2.5, 201.0, 6.5]),
(6, 'normal_air_low', False, False, 100, [6.5, 201.0, 6.5]),
(7, 'normal_water_full', True, False, 300, [10.5, 201.0, 13.5]),
(8, 'normal_water_low', True, False, 100, [13.5, 201.0, 13.5]),
]
def expected_air(water, no_ai, initial, elapsed):
if elapsed == 0:
return initial
if no_ai:
return min(300, initial + 4 * elapsed)
return 300 if water else initial - elapsed
def program(full=False):
ops = []
def cmd(text, label, expected=None, admission=True, diagnostic=False):
op = dict(op='cmd', cmd=text, label=label, admission=admission)
if expected is not None:
op['expect_result'] = expected
if diagnostic and not full:
op.update(record=False, trace=True)
ops.append(op)
def tick(n, label):
cmd(f'/tick step {n}', label + '-request')
ops.append(dict(op='tick', n=n))
cmd('/tick freeze', 'freeze')
for key in ['spawn_mobs', 'advance_time', 'mob_griefing']:
cmd('/gamerule ' + key + ' false', key)
cmd('/gamerule random_tick_speed 0', 'random-ticks')
cmd('/forceload add 0 0 15 15', 'force', 1)
ops.append(dict(op='settle', required_chunks=[[0, 0]]))
cmd('/fill 0 199 0 15 199 15 stone', 'floor', 256)
cmd('/fill 0 200 0 15 205 15 air', 'clear')
cmd('/fill 8 200 8 15 204 15 glass', 'pool-shell', 320)
cmd('/fill 9 200 9 14 204 14 water', 'pool-water', 180)
cmd('/tp @s 1.5 200 1.5 0 0', 'owner-position', 1)
cmd('/gamemode creative @s', 'owner-mode')
cmd('/execute if entity @s[type=player,name=Player]', 'owner-type', 1)
for key, value in [('UUID', 4), ('Health', 20), ('playerGameType', 1), ('Pos', 3)]:
cmd('/data get entity @s ' + key, 'owner-' + key, value)
cmd('/scoreboard objectives add h071 dummy', 'objective')
cmd('/execute store result score start h071 run time query gametime', 'clock-start', diagnostic=True)
for i, label, water, no_ai, initial, pos in subjects():
xyz = ' '.join(map(str, pos))
body = '{UUID:[I;71,0,0,%d],Tags:["h071_%s"],Health:10f,NoAI:%db,NoGravity:1b,PersistenceRequired:1b,Air:%ds,attributes:[{id:"minecraft:movement_speed",base:0.0d}]}' % (i, label, no_ai, initial)
cmd(f'/summon nautilus {xyz} {body}', label + '-summon')
cmd(f'/execute if loaded {int(pos[0])} 201 {int(pos[2])}', label + '-loaded', 1)
cmd(f'/execute if block {int(pos[0])} 199 {int(pos[2])} stone', label + '-floor', 1)
for y in [201, 202]:
block = 'water[level=0]' if water else 'air'
cmd(f'/execute if block {int(pos[0])} {y} {int(pos[2])} {block}', f'{label}-medium-{y}', 1)
for elapsed in [0, 1, 2]:
if elapsed:
tick(1, f'tick{elapsed}')
for i, label, water, no_ai, initial, pos in subjects():
uid = f'00000047-0000-0000-0000-{i:012x}'
phase = f'{label}-{elapsed}'
body = '{UUID:[I;71,0,0,%d],Tags:["h071_%s"],Health:10f,NoGravity:1b,PersistenceRequired:1b}' % (i, label)
cmd(f'/execute if entity @e[type=nautilus,nbt={body}]', phase + '-identity', 1)
cmd(f'/execute {"if" if no_ai else "unless"} entity @e[type=nautilus,nbt={{UUID:[I;71,0,0,{i}],NoAI:1b}}]', phase + '-ai', 1)
cmd(f'/data get entity {uid} Pos', phase + '-position')
for field in ['result', 'success']:
cmd(f'/scoreboard players set {field} h071 -99', phase + '-reset-' + field, -99)
value = expected_air(water, no_ai, initial, elapsed)
cmd(f'/execute store result score result h071 store success score success h071 run data get entity {uid} Air', phase + '-air-read', admission=False)
cmd('/scoreboard players get result h071', phase + '-air-result', admission=False)
cmd('/scoreboard players get success h071', phase + '-air-success', 1)
cmd(f'/execute if entity @e[type=nautilus,nbt={{UUID:[I;71,0,0,{i}],Air:{value}s}}]', phase + '-air-typed', admission=False)
cmd(f'/data get entity {uid}', phase + '-body', diagnostic=True)
cmd('/execute store result score end h071 run time query gametime', 'clock-end', diagnostic=True)
cmd('/scoreboard players operation end h071 -= start h071', 'clock-delta-operation', 2)
cmd('/scoreboard players get end h071', 'clock-delta', 2)
return ops
def main():
full = '--full-diagnostic' in sys.argv
if full:
sys.argv.remove('--full-diagnostic')
reference = None
if '--reference' in sys.argv:
i = sys.argv.index('--reference')
reference = Path(sys.argv[i + 1]).resolve().read_bytes()
del sys.argv[i:i + 2]
if '--binary' not in sys.argv:
raise SystemExit('--binary is required (also for a reference-only run)')
build = Path(sys.argv[sys.argv.index('--work-dir') + 1]).resolve()
ops = program(full)
driver.corpus_module.groups = lambda: [('NoAI nautilus air', ops)]
sys.argv.append('--allow-gaps')
driver.main()
recorded = [o for o in ops if o['op'] == 'cmd' and o.get('record', True)]
java_bytes = (build / 'vanilla.jsonl').read_bytes()
java = [json.loads(s) for s in java_bytes.splitlines()]
rows = {o['label']: r for o, r in zip(recorded, java)}
assert len(recorded) == len(java)
for elapsed in [0, 1, 2]:
for i, label, water, no_ai, initial, pos in subjects():
value = expected_air(water, no_ai, initial, elapsed)
assert rows[f'{label}-{elapsed}-air-read']['results'] == [[True, value]]
assert rows[f'{label}-{elapsed}-air-typed']['results'] == [[True, 1]]
if '--reference-only' in sys.argv:
return
if reference and not full:
assert reference == java_bytes, 'fresh reference changed'
comparison = reference or java_bytes
(build / 'comparison-reference.jsonl').write_bytes(comparison)
java = [json.loads(s) for s in comparison.splitlines()]
native = [json.loads(s) for s in (build / 'rust.jsonl').read_bytes().splitlines()]
assert len(recorded) == len(java) == len(native)
differences = [dict(label=o['label'], expected=a, actual=b) for o, a, b in zip(recorded, java, native) if a != b]
report = dict(records=len(native), passed=len(native)-len(differences), differences=differences,
reference_sha256=hashlib.sha256(comparison).hexdigest(), comparison='complete records; no masks or tolerances')
(build / 'strict-report.json').write_text(json.dumps(report, indent=2)+'\n')
for o, a, b in zip(recorded, java, native):
if o['admission'] and not o['label'].endswith('-body') and o['label'] not in ['clock-start', 'clock-end']:
assert a == b, ('admission', o, a, b)
print(f'Strict raw records: {report["passed"]}/{report["records"]}')
if differences:
raise SystemExit(1)
if __name__ == '__main__':
main()