McRogueFace/tests/fuzz/fuzz_fov.py
John McCardle 246ed886db Fold Tier C surface into existing fuzz targets; closes #312
Extends the five existing targets to cover the remaining gaps from #312
without new files:

- property_types     Line/Circle/Arc setters, Scene.children collection ops
                     (index/count/find/insert/slice/pop), module functions
                     find/find_all/bresenham/lock. Benchmark triplet excluded
                     (end_benchmark writes a file per call).
- grid_entity        grid.at / [x,y] / entities_in_radius / center_camera /
                     hovered_cell, and GridPoint named-layer __getattr__/
                     __setattr__.
- pathfinding_behavior  Grid.find_path + full AStarPath (peek/__len__/__bool__/
                     iteration) that path_from didn't reach.
- fov                ColorLayer perspective (apply/update/clear_perspective)
                     and draw_fov.
- maps_procgen       ColorLayer/TileLayer apply_threshold/apply_ranges/
                     apply_gradient from HeightMap sources.

The full instrumented campaign surfaced five new bugs, filed as #321 (HIGH
ColorLayer.draw_fov bad-free), #322 (WangSet.terrain_enum error-pending
abort), #323/#324/#325 (float->int UB in pitch_shift/hsl_shift/Vector). Per
decision, this issue delivers fuzz coverage only; the bugs are tracked
separately.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KnywUddaFRhkxo5kijxJnv
2026-06-21 16:45:03 -04:00

355 lines
14 KiB
Python

"""Fuzz target for grid Field-of-View computation (#283).
Exercises ``UIGrid.compute_fov`` / ``UIGrid.is_in_fov`` and the underlying
TCOD FOV state. The mutation surface intentionally hammers:
- transparency / walkability toggles between consecutive computes
(catches stale FOV state bugs)
- in-bounds and out-of-bounds origins (must raise, not crash)
- radius edge cases: 0, normal, far larger than the grid
- ``light_walls`` flag flipping
- every value of the ``mcrfpy.FOV`` enum (BASIC, DIAMOND, SHADOW,
PERMISSIVE_0..8, RESTRICTIVE, SYMMETRIC_SHADOWCAST) and raw int values
including out-of-range integers (must raise ValueError, not crash)
- alternating compute/query loops where the origin walks across the grid
Grid sizes are kept small (2x2 .. 32x32) because compute_fov is the
per-iteration hot path here.
"""
import mcrfpy
from fuzz_common import ByteStream, EXPECTED_EXCEPTIONS, safe_reset
def _get_fov_enum_members():
"""Snapshot mcrfpy.FOV enum members once at import time.
Returns a list of FOV enum instances. Falls back to integers if the
enum module is unavailable for any reason — the C++ binding accepts
raw ints too, so the fuzz target stays functional either way.
"""
fov = getattr(mcrfpy, "FOV", None)
if fov is None:
return list(range(15)) # FOV_BASIC..NB_FOV_ALGORITHMS-1
members = []
for name in (
"BASIC", "DIAMOND", "SHADOW",
"PERMISSIVE_0", "PERMISSIVE_1", "PERMISSIVE_2", "PERMISSIVE_3",
"PERMISSIVE_4", "PERMISSIVE_5", "PERMISSIVE_6", "PERMISSIVE_7",
"PERMISSIVE_8",
"RESTRICTIVE", "SYMMETRIC_SHADOWCAST",
):
m = getattr(fov, name, None)
if m is not None:
members.append(m)
if not members:
return list(range(15))
return members
_FOV_MEMBERS = _get_fov_enum_members()
def _make_grid(stream):
"""Create a fresh small grid and return (grid, w, h)."""
w = stream.int_in_range(2, 32)
h = stream.int_in_range(2, 32)
grid = mcrfpy.Grid(grid_size=(w, h))
return grid, w, h
def _color_or_none(stream):
"""Return a (possibly out-of-range) RGBA tuple, or None, for color args."""
if stream.bool():
return None
return (stream.int_in_range(-20, 300), stream.int_in_range(-20, 300),
stream.int_in_range(-20, 300), stream.int_in_range(-20, 300))
def _grid_with_color_layer(stream, name):
"""Build a small grid with one named ColorLayer. Returns (grid, layer, w, h)
or (None, None, 0, 0) if construction failed."""
w = stream.int_in_range(2, 16)
h = stream.int_in_range(2, 16)
try:
grid = mcrfpy.Grid(grid_size=(w, h))
layer = mcrfpy.ColorLayer(name=name, z_index=0)
grid.add_layer(layer)
except EXPECTED_EXCEPTIONS:
return None, None, 0, 0
return grid, layer, w, h
def _fuzz_perspective(stream):
"""ColorLayer.apply_perspective / update_perspective / clear_perspective."""
grid, layer, w, h = _grid_with_color_layer(stream, "persp")
if layer is None:
return
ent = None
try:
ent = mcrfpy.Entity(grid_pos=(stream.int_in_range(0, w - 1),
stream.int_in_range(0, h - 1)), grid=grid)
ent.sight_radius = stream.int_in_range(-2, 20)
except EXPECTED_EXCEPTIONS:
pass
# Sometimes pass a non-entity to hit the type-check path.
target = ent if (ent is not None and stream.bool()) else stream.pick_one((None, "bad", ent))
try:
layer.apply_perspective(target, _color_or_none(stream),
_color_or_none(stream), _color_or_none(stream))
except EXPECTED_EXCEPTIONS:
pass
try:
layer.update_perspective()
except EXPECTED_EXCEPTIONS:
pass
try:
layer.clear_perspective()
except EXPECTED_EXCEPTIONS:
pass
def _fuzz_draw_fov(stream):
"""ColorLayer.draw_fov(source, radius, fov, visible, discovered, unknown)."""
grid, layer, w, h = _grid_with_color_layer(stream, "fov")
if layer is None:
return
source = (stream.int_in_range(-3, w + 3), stream.int_in_range(-3, h + 3))
kw = {}
if stream.bool():
kw["radius"] = stream.int_in_range(-2, 40)
if stream.bool():
kw["fov"] = stream.pick_one(_FOV_MEMBERS)
if stream.bool():
kw["visible"] = _color_or_none(stream)
if stream.bool():
kw["discovered"] = _color_or_none(stream)
try:
layer.draw_fov(source, **kw)
except EXPECTED_EXCEPTIONS:
pass
def fuzz_one_input(data):
stream = ByteStream(data)
try:
grid, w, h = _make_grid(stream)
n_ops = stream.int_in_range(1, 30)
for _ in range(n_ops):
if stream.remaining < 1:
break
op = stream.u8() % 18
try:
if op == 0:
# Replace the active grid (drop the old one).
grid, w, h = _make_grid(stream)
elif op == 1:
# Set transparent on a possibly-OOB cell.
x = stream.int_in_range(-5, w + 5)
y = stream.int_in_range(-5, h + 5)
grid.at(x, y).transparent = stream.bool()
elif op == 2:
# Set walkable on a possibly-OOB cell.
x = stream.int_in_range(-5, w + 5)
y = stream.int_in_range(-5, h + 5)
grid.at(x, y).walkable = stream.bool()
elif op == 3:
# In-bounds compute_fov with reasonable radius.
x = stream.int_in_range(0, max(0, w - 1))
y = stream.int_in_range(0, max(0, h - 1))
radius = stream.int_in_range(0, 20)
grid.compute_fov((x, y), radius=radius)
elif op == 4:
# Out-of-bounds origin: must raise, not crash.
sign_x = -1 if stream.bool() else 1
sign_y = -1 if stream.bool() else 1
x = sign_x * stream.int_in_range(0, w + 5)
y = sign_y * stream.int_in_range(0, h + 5)
if 0 <= x < w and 0 <= y < h:
# Force OOB even if randomness lined up in-bounds.
x = w + 1
radius = stream.int_in_range(0, 10)
grid.compute_fov((x, y), radius=radius)
elif op == 5:
# Radius 0 — degenerate case.
x = stream.int_in_range(0, max(0, w - 1))
y = stream.int_in_range(0, max(0, h - 1))
grid.compute_fov((x, y), radius=0)
elif op == 6:
# Extreme radius (far larger than grid).
x = stream.int_in_range(0, max(0, w - 1))
y = stream.int_in_range(0, max(0, h - 1))
radius = stream.int_in_range(50, 1000)
grid.compute_fov((x, y), radius=radius)
elif op == 7:
# light_walls toggle.
x = stream.int_in_range(0, max(0, w - 1))
y = stream.int_in_range(0, max(0, h - 1))
radius = stream.int_in_range(0, 15)
grid.compute_fov(
(x, y),
radius=radius,
light_walls=stream.bool(),
)
elif op == 8:
# Random algorithm selection (valid enum or int).
x = stream.int_in_range(0, max(0, w - 1))
y = stream.int_in_range(0, max(0, h - 1))
radius = stream.int_in_range(0, 15)
algo = stream.pick_one(_FOV_MEMBERS)
grid.compute_fov(
(x, y),
radius=radius,
light_walls=stream.bool(),
algorithm=algo,
)
elif op == 9:
# Out-of-range raw int algorithm — must raise ValueError.
x = stream.int_in_range(0, max(0, w - 1))
y = stream.int_in_range(0, max(0, h - 1))
bad_algo = stream.int_in_range(-50, 200)
grid.compute_fov(
(x, y), radius=5, algorithm=bad_algo
)
elif op == 10:
# is_in_fov with possibly-OOB coords.
x = stream.int_in_range(-5, w + 5)
y = stream.int_in_range(-5, h + 5)
_ = grid.is_in_fov(x, y)
elif op == 11:
# Tight loop: 20x compute + query against a moving origin.
base_x = stream.int_in_range(0, max(0, w - 1))
base_y = stream.int_in_range(0, max(0, h - 1))
radius = stream.int_in_range(1, 8)
for i in range(20):
ox = (base_x + i) % w
oy = (base_y + i) % h
try:
grid.compute_fov((ox, oy), radius=radius)
_ = grid.is_in_fov(ox, oy)
if w > 1:
_ = grid.is_in_fov((ox + 1) % w, oy)
except EXPECTED_EXCEPTIONS:
pass
elif op == 12:
# Toggle ~20 cells' transparency, recompute, query.
n_toggles = stream.int_in_range(1, 20)
for _ in range(n_toggles):
tx = stream.int_in_range(0, max(0, w - 1))
ty = stream.int_in_range(0, max(0, h - 1))
try:
grid.at(tx, ty).transparent = stream.bool()
except EXPECTED_EXCEPTIONS:
pass
ox = stream.int_in_range(0, max(0, w - 1))
oy = stream.int_in_range(0, max(0, h - 1))
grid.compute_fov((ox, oy), radius=stream.int_in_range(1, 15))
# Query a handful of cells, including OOB.
for _ in range(5):
qx = stream.int_in_range(-2, w + 2)
qy = stream.int_in_range(-2, h + 2)
try:
_ = grid.is_in_fov(qx, qy)
except EXPECTED_EXCEPTIONS:
pass
# More toggles, recompute, requery.
for _ in range(stream.int_in_range(1, 10)):
tx = stream.int_in_range(0, max(0, w - 1))
ty = stream.int_in_range(0, max(0, h - 1))
try:
grid.at(tx, ty).transparent = stream.bool()
except EXPECTED_EXCEPTIONS:
pass
ox = stream.int_in_range(0, max(0, w - 1))
oy = stream.int_in_range(0, max(0, h - 1))
grid.compute_fov((ox, oy), radius=stream.int_in_range(0, 25))
for _ in range(5):
qx = stream.int_in_range(-2, w + 2)
qy = stream.int_in_range(-2, h + 2)
try:
_ = grid.is_in_fov(qx, qy)
except EXPECTED_EXCEPTIONS:
pass
elif op == 13:
# Read-only properties: fov_radius, perspective.
try:
_ = grid.fov_radius
except EXPECTED_EXCEPTIONS:
pass
try:
grid.fov_radius = stream.int_in_range(-5, 50)
except EXPECTED_EXCEPTIONS:
pass
try:
_ = grid.perspective
except EXPECTED_EXCEPTIONS:
pass
elif op == 14:
# Type-confusion: pass garbage as pos / radius / algorithm.
bad_choice = stream.u8() % 5
if bad_choice == 0:
grid.compute_fov(None, radius=5)
elif bad_choice == 1:
grid.compute_fov("not a tuple", radius=5)
elif bad_choice == 2:
grid.compute_fov((1, 2, 3), radius=5)
elif bad_choice == 3:
x = stream.int_in_range(0, max(0, w - 1))
y = stream.int_in_range(0, max(0, h - 1))
grid.compute_fov((x, y), radius="five")
else:
x = stream.int_in_range(0, max(0, w - 1))
y = stream.int_in_range(0, max(0, h - 1))
grid.compute_fov((x, y), radius=5, algorithm="basic")
elif op == 15:
# is_in_fov garbage args.
bad_choice = stream.u8() % 3
if bad_choice == 0:
_ = grid.is_in_fov(None, None)
elif bad_choice == 1:
_ = grid.is_in_fov("a", "b")
else:
_ = grid.is_in_fov((1, 2, 3))
elif op == 16:
# Tier C (#312): ColorLayer perspective system. Build a
# self-contained grid + ColorLayer + Entity so the entity
# visibility-perspective path resolves to real objects.
_fuzz_perspective(stream)
else: # op == 17
# Tier C (#312): ColorLayer.draw_fov from a source cell.
_fuzz_draw_fov(stream)
except EXPECTED_EXCEPTIONS:
pass
except EXPECTED_EXCEPTIONS:
pass
# When invoked directly via --exec (smoke test path), run a single iteration
# against a tiny canned input so the script is self-contained.
if __name__ == "__main__":
import sys
safe_reset()
fuzz_one_input(b"\x05\x05\x10\x03\x02\x02\x05\x07\x01\x01\x01\x0c\x03\x03\x05")
print("PASS")
sys.exit(0)