From bba72cb33b58213fb38e1e2b4390aa712685ab4b Mon Sep 17 00:00:00 2001 From: John McCardle Date: Fri, 10 Apr 2026 11:15:01 -0400 Subject: [PATCH] Add fuzz_fov target, addresses #283 Random compute_fov/is_in_fov exercises with varying origin, radius, light_walls, algorithm params including out-of-bounds origins and extreme radii. Toggles grid.at(x,y).transparent between computes to stress stale fov-state bugs. Co-Authored-By: Claude Opus 4.6 (1M context) --- tests/fuzz/fuzz_fov.py | 273 +++++++++++++++++++++++++++ tests/fuzz/seeds/fov/seed_basic.bin | 1 + tests/fuzz/seeds/fov/seed_oob.bin | 1 + tests/fuzz/seeds/fov/seed_toggle.bin | Bin 0 -> 47 bytes 4 files changed, 275 insertions(+) create mode 100644 tests/fuzz/fuzz_fov.py create mode 100644 tests/fuzz/seeds/fov/seed_basic.bin create mode 100644 tests/fuzz/seeds/fov/seed_oob.bin create mode 100644 tests/fuzz/seeds/fov/seed_toggle.bin diff --git a/tests/fuzz/fuzz_fov.py b/tests/fuzz/fuzz_fov.py new file mode 100644 index 0000000..a2b62a8 --- /dev/null +++ b/tests/fuzz/fuzz_fov.py @@ -0,0 +1,273 @@ +"""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 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() % 16 + 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") + + else: # 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)) + + 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) diff --git a/tests/fuzz/seeds/fov/seed_basic.bin b/tests/fuzz/seeds/fov/seed_basic.bin new file mode 100644 index 0000000..0dc90c1 --- /dev/null +++ b/tests/fuzz/seeds/fov/seed_basic.bin @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tests/fuzz/seeds/fov/seed_oob.bin b/tests/fuzz/seeds/fov/seed_oob.bin new file mode 100644 index 0000000..342d5be --- /dev/null +++ b/tests/fuzz/seeds/fov/seed_oob.bin @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tests/fuzz/seeds/fov/seed_toggle.bin b/tests/fuzz/seeds/fov/seed_toggle.bin new file mode 100644 index 0000000000000000000000000000000000000000..21772bb7568956cc0d31afbef61a2431b49db31f GIT binary patch literal 47 lcmXYl$qfJ?48t5({Mdh98i|(=%@vZMfm7F+MXmW?&jSZc073u& literal 0 HcmV?d00001