Classify the verified hostile sound families as hostile

Co-Authored-By: Codex <[email protected]>
This commit is contained in:
Florian ERNSTandCodex committed 2026-11-06 04:38:19 +01:00
1 parent 69fab597a6
commit 6303017b03
4 files changed
+471 -1

No files matched your search

+62 -1
View File
@@ -18,7 +18,7 @@ pub fn set_source_volumes(volumes: [f32; 11]) {
pub fn sound_source(event: &str) -> usize {
let e = event.trim_start_matches("minecraft:");
let index = |name: &str| SOUND_SOURCES.iter().position(|n| *n == name).unwrap_or(0);
const HOSTILE: [&str; 25] = [
const HOSTILE: [&str; 34] = [
"zombie",
"skeleton",
"creeper",
@@ -44,6 +44,16 @@ pub fn sound_source(event: &str) -> usize {
"silverfish",
"zombified_piglin",
"wither_skeleton",
"bogged",
"breeze",
"endermite",
"parched",
"warden",
"zombie_villager",
"vex",
"wither",
// Monster's common hurt/death (e.g. Giant) and other shared sounds.
"hostile",
];
if e == "block.potent_sulfur.noxious_gas" {
// PotentSulfurBlock.animateTick, its only caller, plays it as AMBIENT.
@@ -856,6 +866,57 @@ mod tests {
horizon_narrator::Status::Off
);
}
/// `Monster.getSoundSource`: the verified families and inherited common sounds.
#[test]
fn hostile_families_keep_their_sound_category() {
for (event, source) in [
("entity.zombie.hurt", 5),
("entity.cow.hurt", 6),
("block.stone.break", 4),
("entity.player.hurt", 7),
("entity.arrow.hit", 7),
("music_disc.13", 2),
("item.goat_horn.sound.0", 2),
("weather.rain", 3),
("ambient.cave", 8),
] {
assert_eq!(sound_source(event), source, "{event}");
}
// Explicit sources still take precedence, including H331's arrow route.
let mut audio = crate::audio::Audio::silent(Path::new("/nonexistent/h068-audio"));
audio.capture = Some(Vec::new());
audio.play_on(6, "minecraft:entity.arrow.hit", Some([1.0; 3]), 1.0, 1.0);
audio.play_on(4, "minecraft:entity.warden.hurt", Some([1.0; 3]), 1.0, 1.0);
assert_eq!(
audio
.capture
.as_ref()
.unwrap()
.iter()
.map(|s| s.source)
.collect::<Vec<_>>(),
[6, 4]
);
for family in [
"bogged",
"breeze",
"endermite",
"parched",
"warden",
"zombie_villager",
"vex",
"wither",
"hostile",
] {
for suffix in ["hurt", "death"] {
for prefix in ["", "minecraft:"] {
let event = format!("{prefix}entity.{family}.{suffix}");
assert_eq!(sound_source(&event), 5, "{event}");
}
}
}
}
#[test]
fn three_d_skin_layers_move_from_options_txt_to_the_extras_file() {
// #8091 wrote the switch into options.txt; a player who had it on keeps
+125
View File
@@ -0,0 +1,125 @@
# Verified hostile sound families
H068 adds only bogged, breeze, endermite, parched, warden, zombie_villager, vex,
wither and the shared `entity.hostile` family to inferred HOSTILE5. Pinned26.3
`Monster.getSoundSource` returns HOSTILE; the eight verified types inherit that
source. `Monster.getHurtSound/getDeathSound` supply the common pair used by Giant
(merged#7834). Existing25 names, other branches, normalization, explicit sources,
volumes, names, timing and RNG calls are unchanged. No all-hostile census claim.
## Frozen live category contract
Base `69fab597a6f0e8a6ce524d38d1b73e599e343034`. Fresh Java26.3 generated the small
loaded platform/roof world; both actual dedicated Game runs serve independent
copies. One real connected Alice has actual UUID/type/Health20/creative/Pos guards.
Twelve distinct fixed-UUID actors have typed species/Health/NoAI/NoGravity and
loaded floor/air guards. Eleven fresh hurt cases set Health6, require accepted
result/success1 with fresh sentinels, and independently check live Health5. One
fresh Giant takes a lethal hit; raw snapshots independently show Health0.
Eight assigned hostile types, Giant, zombie and cow supply the hurt events;
Giant supplies common death. Each hit is followed by exactly one real world step
to drain native events, for12 total. No hit is retried and no extra world step is
used for sound readiness. Receipt barriers wait only for the actual packet. This
does not claim identical Java/native emission timing. Body/position snapshots
remain raw; post-hit trajectories are not compared.
Predeclared16-row contract: stage, event name, source ordinal and sound position.
Actor/control admissions remain required on both sides. Zombie5 and cow6 are
live controls; explicit block4/player7/record2 and Warden-neutral6 override are
controls. Control volume/pitch1 bits are exact. Random hurt pitch/volume must be
finite/positive; no exact random pitch, seed or full packet-family comparison.
No Breeze ambient output is required (H069 remains independent).
The existing typed SoundPacket switch is reused unchanged from own#10266 in
`LocatorBarClient`: vanilla's protocol decoder reads both servers and retains
all positional sound payload bytes, names, source names, volume/pitch bits,
seeds and arrival order. Nothing is reconstructed from a desired category. All
recorded sound rows stay raw, including any extra ambient sound; only the
predeclared event contract is canonical. No new observer/protocol implementation.
H128 production and H331 arrow production/decoder are not imported or changed.
Keep the identical test-only receipt switch once when combining#10266, while
preserving both drivers.
Program JSON SHA-256 (declared before fresh Java/baseline):
`fc1d6ae79de25bf520ed8e511b9e20a43f1bb4012b6fbcfee7cb0b8ba526c39f`.
```sh
python3 tools/test_hostile_sound_categories_parity.py --binary /path/to/old-game --reference-only --work-dir /tmp/h068-java
python3 tools/test_hostile_sound_categories_parity.py --binary /path/to/old-game --reference-dir /tmp/h068-java --work-dir /tmp/h068-old
python3 tools/test_hostile_sound_categories_parity.py --binary /path/to/fixed-game --reference-dir /tmp/h068-java --work-dir /tmp/h068-fixed
```
Each output directory is fresh; references are immutable. The owned untouched
69fab executable was reused from the completed H041 baseline after source/hash
verification, not from a historical capture. SHA-256:
`440bc67f89ce05a1691798cde40a5f9c05bfb61699cb124a132803672433b3bd`.
The H068 captures themselves are fresh. Fixed Game is independently built from
the same frozen base plus this classifier change.
## Unit and evidence limits
The resource-free classifier regression first verifies existing zombie/cow,
block/player/record/horn/weather/ambient categories and actual `Audio::play_on`
overrides (arrow6 and Warden4). It then checks hurt/death for all nine additions,
with and without the namespace. Old fails at bogged6 versus5; fixed passes. It
preserves the existing arrow inference branch and checks override precedence;
no live arrow-route parity or H331 fix is claimed.
All program/reference hashes, raw typed sound packets/payloads/seeds/pitch/order,
chat, complete actor bodies, command sent/receipt times, actual clocks, logs,
source/header review and immutable executable evidence remain under `.local/h068/`.
Command/stage timestamps are real UTC observations. Packet receipt and sound
emission timestamps are unavailable and explicitly null; original reused build
start is null. Available build/check elapsed times are retained, not inferred.
Verified inactive owned executable archives retain original paths, modes and
hashes; no shared dependency tree or other owner's artifact was removed.
No Breeze ambient routing, mob damage policy, AI/physics, exact random stream,
full sound-event/packet family, client rendering, mixer or reopen closure claim.
## Results
Fresh Java16/16; untouched current Game **6/16 → fixed16/16**. All actor/action,
position, override and twelve-tick admissions pass on both Game binaries.
| Stage / event | Java | Old Game | Fixed Game |
| --- | --- | --- | --- |
| `control_block` / `minecraft:block.note_block.harp` | 4 | 4 | 4 |
| `control_player` / `minecraft:entity.player.hurt` | 7 | 7 | 7 |
| `control_record` / `minecraft:music_disc.13` | 2 | 2 | 2 |
| `control_override` / `minecraft:entity.warden.hurt` | 6 | 6 | 6 |
| `bogged` / `minecraft:entity.bogged.hurt` | 5 | 6 | 5 |
| `breeze` / `minecraft:entity.breeze.hurt` | 5 | 6 | 5 |
| `endermite` / `minecraft:entity.endermite.hurt` | 5 | 6 | 5 |
| `parched` / `minecraft:entity.parched.hurt` | 5 | 6 | 5 |
| `warden` / `minecraft:entity.warden.hurt` | 5 | 6 | 5 |
| `zombie_villager` / `minecraft:entity.zombie_villager.hurt` | 5 | 6 | 5 |
| `vex` / `minecraft:entity.vex.hurt` | 5 | 6 | 5 |
| `wither` / `minecraft:entity.wither.hurt` | 5 | 6 | 5 |
| `giant` / `minecraft:entity.hostile.hurt` | 5 | 6 | 5 |
| `zombie` / `minecraft:entity.zombie.hurt` | 5 | 5 | 5 |
| `cow` / `minecraft:entity.cow.hurt` | 6 | 6 | 6 |
| `giant_death` / `minecraft:entity.hostile.death` | 5 | 6 | 5 |
Only the ten affected category ordinals change in the canonical contract; no
category mask or error normalization. Raw seeds/pitch/packet encodings/order
are preserved separately and are not claimed byte-equal across independent runs.
Immutable fresh Java reference SHA-256:
`f043ae5906dd9e9fabaa9ddd23255ceb0299e0bbe5b86e14fd80bf85b02a98c9`. Program, observer and reference hashes are verified
unchanged across old/fixed captures. Giant death raw snapshots show Health0 on
both sides. No source modification was needed outside the family table.
## Local checks and source checkpoint
Final production/harness:1517 renderer tests, formatting, workspace/server
Clippy, doc tests and public-tree checks pass. Python696 has four existing
macOS tooling failures (ARM installer-fixture assets and three Bash3.2 optional
archive cases), one skip. Narrow installer diagnostic retained; no packaging fix.
Later main1532c9c15 adds tick tracking/post-AI gates, LAN caching and player
metadata. Classifier/audio dispatch are unchanged; nearby NoAI hurt cases have
no pickup/search inputs affected by those post-passes. This is source review,
not a latest-base capture. Proof remains frozen69fab. Subsequent edits only add
evidence notes; final public-tree/whitespace checks pass.
@@ -103,6 +103,7 @@ import net.minecraft.world.phys.Vec3;
* player's lines to OUT. {@code boats on|off} records boat paddle metadata;
* {@code hooks on|off} records the entity a fishing bobber hooks, by type;
* {@code paddle NAME LEFT RIGHT} sends the two boolean paddle states;
* {@code sounds on|off} records typed sound packets and their complete payloads;
* {@code events on|off} records each level event: its id, block and data;
* {@code spawns on|off} records each entity other than a player added to the
* level, by type and position.
@@ -115,6 +116,7 @@ public final class LocatorBarClient {
private static volatile boolean boats;
private static volatile boolean hooks;
private static volatile boolean state;
private static volatile boolean sounds;
private static volatile boolean events;
private static volatile boolean spawns;
private static volatile boolean menus;
@@ -202,6 +204,7 @@ public final class LocatorBarClient {
Boolean.parseBoolean(sides[0]), Boolean.parseBoolean(sides[1])));
}
case "state" -> state = w[1].equals("on");
case "sounds" -> sounds = w[1].equals("on");
case "events" -> events = w[1].equals("on");
case "spawns" -> spawns = w[1].equals("on");
case "end" -> {
@@ -548,6 +551,22 @@ public final class LocatorBarClient {
if (tickSteps && p instanceof net.minecraft.network.protocol.game.ClientboundBlockEventPacket e
&& e.getPos().equals(new net.minecraft.core.BlockPos(0, 80, 0)))
record("piston-event " + e.getB0() + " " + e.getB1());
if (sounds && p instanceof net.minecraft.network.protocol.game.ClientboundSoundPacket sound) {
com.google.gson.JsonObject j = new com.google.gson.JsonObject();
j.addProperty("event", sound.getSound().value().location().toString());
j.addProperty("source", sound.getSource().ordinal());
j.addProperty("source_name", sound.getSource().name());
com.google.gson.JsonArray at = new com.google.gson.JsonArray();
at.add(sound.getX()); at.add(sound.getY()); at.add(sound.getZ());
j.add("position", at);
j.addProperty("volume", sound.getVolume());
j.addProperty("pitch", sound.getPitch());
j.addProperty("volume_bits", Float.floatToRawIntBits(sound.getVolume()));
j.addProperty("pitch_bits", Float.floatToRawIntBits(sound.getPitch()));
j.addProperty("seed", sound.getSeed());
j.addProperty("payload_hex", java.util.HexFormat.of().formatHex(r.payload));
record("sound-wire " + j);
}
if (state) recordState(p);
if (menus) recordMenu(p, r.payload);
if (hooks && p instanceof net.minecraft.network.protocol.game.ClientboundAddEntityPacket a) {
@@ -0,0 +1,265 @@
#!/usr/bin/env python3
"""Live hurt/death sound categories, through the existing typed network capture.
Seeds, pitch, payloads, packet order, chat, bodies and real clocks stay raw.
Only the predeclared event/category/position contract is compared across sides.
"""
import argparse
import datetime
import hashlib
import json
import math
from pathlib import Path
import re
import time
import test_locator_bar_parity as net
from test_dedicated_server_parity import Server, game_time
PLAYER = 'Alice'
FAMILIES = ['bogged', 'breeze', 'endermite', 'parched', 'warden', 'zombie_villager', 'vex', 'wither', 'giant', 'zombie', 'cow']
CONTROLS = [
('block', 'minecraft:block.note_block.harp', 'block', 4),
('player', 'minecraft:entity.player.hurt', 'player', 7),
('record', 'minecraft:music_disc.13', 'record', 2),
('override', 'minecraft:entity.warden.hurt', 'neutral', 6),
]
def now():
return datetime.datetime.now(datetime.timezone.utc).isoformat()
def uuid_words(name):
raw = bytearray(hashlib.md5(('OfflinePlayer:' + name).encode()).digest())
raw[6] = (raw[6] & 15) | 48
raw[8] = (raw[8] & 63) | 128
return ','.join(str(int.from_bytes(raw[i:i+4], 'big', signed=True)) for i in range(0, 16, 4))
def actor(index):
return f'00000044-0000-0000-0000-{index:012x}'
def program():
expected = [dict(stage='control_' + label, event=event, source=source, position=[4.5, 100.0, 4.5])
for label, event, _, source in CONTROLS]
for kind in FAMILIES:
family = 'hostile' if kind == 'giant' else kind
expected.append(dict(stage=kind, event=f'minecraft:entity.{family}.hurt', source=6 if kind == 'cow' else 5,
position=[8.5, 100.0, 8.5]))
expected.append(dict(stage='giant_death', event='minecraft:entity.hostile.death', source=5, position=[8.5, 100.0, 8.5]))
return dict(player=PLAYER, families=FAMILIES, controls=CONTROLS, expected=expected,
contract=['stage', 'event', 'source', 'position'], world_ticks=12,
damage='1 minecraft:out_of_world (fresh Health6→5); fresh giant death uses100',
diagnostic_fields=['source_name', 'volume', 'pitch', 'volume_bits', 'pitch_bits', 'seed', 'payload_hex', 'receipt_order'],
sound_emission_time=None, packet_receipt_time=None,
scope='Positional sound category and admitted live actor/action; no exact random pitch/seed/trajectory or packet-family claim')
def dump(client, label):
client.tell('dump ' + label)
while (line := client.process.stdout.readline()) != 'dumped ' + label + '\n':
if not line:
raise AssertionError('client exited before dump')
client.log.write(line)
def raw_sounds(path):
out = []
if path.exists():
for order, line in enumerate(path.read_text().splitlines()):
who, rest = line.split(' step=', 1)
stage, kind, data = rest.split(' ', 2)
assert who == PLAYER
if kind == 'sound-wire':
out.append(dict(stage=stage, receipt_order=order, **json.loads(data)))
return out
class Commands:
def __init__(self, server, path):
self.server, self.path, self.serial, self.rows = server, path, 0, []
def reply(self, command):
self.serial += 1
start = len(self.server.lines)
row = dict(command=command, sent_at=now(), receipt_at=None, lines=[])
self.rows.append(row)
self.server.console(command)
self.server.console(f'data modify storage h068:barrier value set value {self.serial}')
self.server.console('data get storage h068:barrier value')
try:
self.server.wait_for(rf'h068:barrier.*contents: {self.serial}$', timeout=90)
row['receipt_at'] = now()
return '\n'.join(self.server.lines[start:])
finally:
row['lines'] = self.server.lines[start:]
self.path.write_text(json.dumps(self.rows, indent=2) + '\n')
def passed(self, command):
text = self.reply(command)
assert 'Test passed' in text, (command, text)
def generate(java, build):
directory = build / 'generate'
net.server_dir(directory)
server = Server(java, directory, 'generate')
commands = Commands(server, build / 'generate-commands.json')
try:
server.wait_for(r'Done \(')
for rule in ['spawn_mobs', 'advance_time', 'mob_griefing']:
commands.reply(f'gamerule {rule} false')
commands.reply('gamerule respawn_radius 0')
commands.reply('forceload add 0 0 15 15')
deadline = time.monotonic() + 90
while 'Test passed' not in commands.reply('execute if loaded 8 100 8'):
assert time.monotonic() < deadline, 'generation readiness'
time.sleep(.05)
assert '256' in commands.reply('fill 0 99 0 15 99 15 stone')
commands.reply('fill 0 100 0 15 115 15 air')
assert '256' in commands.reply('fill 0 116 0 15 116 15 stone')
commands.reply('setworldspawn 2 100 2')
commands.reply('save-all flush')
finally:
server.stop()
return directory / 'world'
def run(label, command, client_command, build, world, spec):
directory = build / label
port = net.server_dir(directory, world)
server = Server(command, directory, label)
commands = Commands(server, build / (label + '-commands.json'))
path = build / (label + '-wire.txt')
client_log = (build / (label + '-client.log')).open('w')
client, timeline, checks = None, [], []
def mark(stage, **data):
timeline.append(dict(stage=stage, recorded_at=now(), monotonic_ns=time.monotonic_ns(), sound_emission_time=None, **data))
try:
server.wait_for(r'Done \(')
commands.reply('tick freeze')
commands.reply('scoreboard objectives add h068 dummy')
client = net.Client(client_command, port, path, client_log)
for line in ['sounds on', 'chat on', 'spawns on', 'join ' + PLAYER, 'fly Alice on']:
client.tell(line)
server.wait_for('Alice joined the game', timeout=120)
client.sync([PLAYER])
commands.reply('tp Alice 2.5 100 2.5 0 0')
commands.passed('execute if entity @a[name=Alice,nbt={UUID:[I;' + uuid_words(PLAYER) + '],Health:20f,playerGameType:1,Pos:[2.5d,100d,2.5d]}]')
commands.reply('data get entity Alice')
for y, block in [(99, 'stone'), (100, 'air'), (112, 'air'), (115, 'air'), (116, 'stone')]:
commands.passed(f'execute if loaded 8 {y} 8')
commands.passed(f'execute if block 8 {y} 8 {block}')
start = game_time(server)
elapsed = 0
for case in spec['expected']:
stage, event = case['stage'], case['event']
client.tell('step ' + stage); client.sync([PLAYER])
mark(stage, game_time=game_time(server), relative_clock=elapsed)
if stage.startswith('control_'):
_, _, category, _ = next(c for c in CONTROLS if 'control_' + c[0] == stage)
commands.reply(f'playsound {event} {category} Alice 4.5 100 4.5 1 1')
else:
kind = 'giant' if stage == 'giant_death' else stage
index = len(FAMILIES) + 1 if stage == 'giant_death' else FAMILIES.index(kind) + 1
uid = actor(index)
tag = 'h068_' + stage
commands.reply(f'summon minecraft:{kind} 8.5 100 8.5 {{UUID:[I;68,0,0,{index}],Tags:["{tag}"],Health:6f,NoAI:1b,NoGravity:1b,PersistenceRequired:1b,Invul:0}}')
selector = f'@e[type=minecraft:{kind},tag={tag},nbt={{UUID:[I;68,0,0,{index}],Health:6f,NoAI:1b,NoGravity:1b}},distance=..0]'
commands.passed('execute positioned 8.5 100 8.5 if entity ' + selector)
commands.reply(f'data get entity {uid}')
for key in ['hit_result', 'hit_success']:
commands.reply(f'scoreboard players set {key} h068 -99')
amount = 100 if stage == 'giant_death' else 1
commands.reply(f'execute store result score hit_result h068 store success score hit_success h068 run damage {uid} {amount} minecraft:out_of_world')
for key in ['hit_result', 'hit_success']:
text = commands.reply(f'scoreboard players get {key} h068')
assert f'{key} has 1 [h068]' in text, (key, text)
if stage != 'giant_death':
commands.passed(f'execute if entity @e[type=minecraft:{kind},tag={tag},nbt={{UUID:[I;68,0,0,{index}],Health:5f}}]')
commands.reply(f'data get entity {uid}')
commands.reply('tick step 1')
elapsed += 1
deadline = time.monotonic() + 60
while game_time(server) < start + elapsed:
assert time.monotonic() < deadline, 'world step stalled'
time.sleep(.01)
assert game_time(server) == start + elapsed
if stage != 'giant_death':
commands.passed(f'execute if entity @e[type=minecraft:{kind},tag={tag},nbt={{UUID:[I;68,0,0,{index}],Health:5f,NoAI:1b,NoGravity:1b}}]')
commands.reply(f'data get entity {uid}')
# Wait for receipt only: never retry damage or add a world tick.
deadline = time.monotonic() + 30
while True:
client.sync([PLAYER]); dump(client, stage)
matches = [r for r in raw_sounds(path) if r['stage'] == stage and r['event'] == event]
if matches:
break
assert time.monotonic() < deadline, (stage, 'no expected hurt/control sound')
time.sleep(.02)
assert len(matches) == 1, (stage, matches)
sound = matches[0]
assert sound['position'] == case['position'], (stage, sound)
assert math.isfinite(sound['volume']) and sound['volume'] > 0
assert math.isfinite(sound['pitch']) and sound['pitch'] > 0
if stage.startswith('control_'):
assert (sound['volume_bits'], sound['pitch_bits']) == (1065353216, 1065353216)
checks.append(stage)
mark(stage + '-received', game_time=game_time(server), relative_clock=elapsed)
assert game_time(server) - start == spec['world_ticks']
finally:
(build / (label + '-stages.json')).write_text(json.dumps(timeline, indent=2) + '\n')
try:
if client:
try:
dump(client, 'failure-or-final'); client.end()
finally:
client.kill()
finally:
server.stop(); client_log.close()
(build / (label + '-sounds.json')).write_text(json.dumps(raw_sounds(path), indent=2) + '\n')
records = raw_sounds(path)
names = {c['event'] for c in spec['expected']}
contract = [{k: r[k] for k in spec['contract']} for r in records if r['event'] in names]
assert len(contract) == len(spec['expected']), (label, contract)
return dict(contract=contract, admission=checks, total_world_ticks=elapsed,
recorded_at=now(), sound_emission_time=None, packet_receipt_time=None)
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--binary', type=Path)
parser.add_argument('--work-dir', type=Path)
parser.add_argument('--reference-only', action='store_true')
parser.add_argument('--reference-dir', type=Path)
args = parser.parse_args()
build = (args.work_dir or net.lab.LOCAL / 'hostile-sound-categories' / f'run-{time.time_ns()}').resolve()
build.mkdir(parents=True, exist_ok=False)
spec = program(); (build / 'program.json').write_text(json.dumps(spec, indent=2) + '\n')
name = str(build.relative_to(net.lab.LOCAL))
_, _, java, native, client = net.sides(name, args.binary.resolve() if args.binary else None)
reference = args.reference_dir.resolve() if args.reference_dir else build
if not args.reference_dir:
world = generate(java, build)
data = run('java', java, client, build, world, spec)
assert data['contract'] == spec['expected'], data
(build / 'reference.json').write_text(json.dumps(data, indent=2) + '\n')
if args.reference_only:
return
expected_bytes = (reference / 'reference.json').read_bytes()
expected = json.loads(expected_bytes)
assert expected['contract'] == spec['expected']
actual = run('native', native, client, build, reference / 'generate/world', spec)
assert expected_bytes == (reference / 'reference.json').read_bytes()
report = dict(expected=expected, actual=actual, pass_=expected['contract'] == actual['contract'],
reference_sha256=hashlib.sha256(expected_bytes).hexdigest())
(build / 'report.json').write_text(json.dumps(report, indent=2) + '\n')
print('Sound category contract:', sum(a == b for a, b in zip(expected['contract'], actual['contract'])), '/', len(spec['expected']))
if not report['pass_']:
raise SystemExit(1)
if __name__ == '__main__':
main()