Add 4 libFuzzer targets for Tier A/B API surface; addresses #312

New targets under tests/fuzz/, wired into Makefile FUZZ_TARGETS, each with a
seed corpus (parser seeds are real fixtures prefixed with a loader selector
byte):

- fuzz_audio_dsp        SoundBuffer factories + 14 DSP effects + concat/mix.
                        Self-contained (CPU sample math, no device).
- fuzz_import_parsers   TileSetFile/TileMapFile/LdtkProject. Loaders take a
                        path, so each iteration writes mutated bytes to a temp
                        file; OSError (IOError) is swallowed as an expected
                        parse-failure outcome.
- fuzz_texture_factory  Texture.from_bytes/composite/hsl_shift byte ingestion.
                        Multiplication-overflow path documented as out of scope
                        (would OOM, not crash cleanly).
- fuzz_shader_bindings  uniforms[] + PropertyBinding/CallableBinding lifetime,
                        target Drawable destroyed mid-flight (#270/#271/#277
                        pattern). Degrades to pure binding-lifetime fuzzing if
                        shaders are unavailable.

All four signature-validated against the live mcrfpy API before running.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KnywUddaFRhkxo5kijxJnv
This commit is contained in:
John McCardle 2026-06-21 16:44:47 -04:00
commit 925699ef0b
24 changed files with 1204 additions and 4 deletions

View file

@ -120,7 +120,7 @@ asan-test: asan
# a Python `fuzz_one_input(data)` function loaded from the script named by
# the MCRF_FUZZ_TARGET env var. libFuzzer instruments the C++ engine code
# where all the #258-#278 bugs live. No atheris dependency.
FUZZ_TARGETS := grid_entity property_types anim_timer_scene maps_procgen fov pathfinding_behavior
FUZZ_TARGETS := grid_entity property_types anim_timer_scene maps_procgen fov pathfinding_behavior audio_dsp import_parsers texture_factory shader_bindings
FUZZ_SECONDS ?= 30
# Shared env for running the fuzz binary. PYTHONHOME points at the build-fuzz

View file

@ -43,9 +43,19 @@ dir). Seed inputs committed to `tests/fuzz/seeds/<target>/` are read-only.
| `fuzz_grid_entity.py` | EntityCollection append/remove/insert/extend/slice across differently-sized grids, `entity.die` during iteration | #258-#263, #273, #274 |
| `fuzz_property_types.py` | Random property get/set with type confusion on Frame/Caption/Sprite/Entity/Grid/TileLayer/ColorLayer | #267, #268, #272 |
| `fuzz_anim_timer_scene.py` | Animation + Timer state machine, Frame reparenting, scene swap in callbacks | #269, #270, #275, #277 |
| `fuzz_maps_procgen.py` | HeightMap/DiscreteMap ops and conversions, NoiseSource.sample, BSP.to_heightmap | new |
| `fuzz_fov.py` | grid.compute_fov + is_in_fov, transparent toggling | new |
| `fuzz_pathfinding_behavior.py` | DijkstraMap, grid.step, entity behavior fields | #273-adjacent |
| `fuzz_maps_procgen.py` | HeightMap/DiscreteMap ops and conversions, NoiseSource.sample, BSP.to_heightmap, ColorLayer/TileLayer `apply_threshold`/`apply_ranges`/`apply_gradient` | new |
| `fuzz_fov.py` | grid.compute_fov + is_in_fov, transparent toggling, ColorLayer perspective (`apply_perspective`/`update_perspective`/`clear_perspective`/`draw_fov`) | new |
| `fuzz_pathfinding_behavior.py` | DijkstraMap, grid.step, entity behavior fields, `Grid.find_path` + full AStarPath (peek/len/bool/iter) | #273-adjacent |
| `fuzz_audio_dsp.py` | SoundBuffer DSP chain: from_samples/tone/sfxr, concat/mix, pitch_shift/lp/hp/echo/reverb/distortion/bit_crush/gain/normalize/reverse/slice/sfxr_mutate | #312 |
| `fuzz_import_parsers.py` | Tiled/LDtk external file parsers (`TileSetFile`/`TileMapFile`/`LdtkProject`) via temp-file mutation of real fixtures | #312 |
| `fuzz_texture_factory.py` | `Texture.from_bytes`/`composite`/`hsl_shift` byte-ingestion + pixel transforms | #312 |
| `fuzz_shader_bindings.py` | Shader uniform binding lifetime: `uniforms[]`, `PropertyBinding`/`CallableBinding`, target destroyed mid-flight (#270/#271/#277 pattern) | #312 |
Tier C surfaces from #312 are folded into existing targets rather than new
files: Line/Circle/Arc, `Scene.children` collection ops, and `find`/`find_all`/
`bresenham`/`lock` live in `fuzz_property_types.py`; grid spatial queries +
GridPoint dynamic attrs in `fuzz_grid_entity.py`. (The benchmark triplet is
deliberately excluded — `end_benchmark()` writes a file per call.)
Any target not yet implemented is a stub that still compiles and runs cleanly
`make fuzz` reports it as a no-op.

View file

@ -0,0 +1,194 @@
"""fuzz_audio_dsp - SoundBuffer DSP chain fuzzing (#312, Tier B).
Targets the 14 signal-processing methods + factory/composition entry points
on mcrfpy.SoundBuffer. None of these were exercised by the #283 harness.
Surface (verified against src/PySoundBuffer.cpp and src/audio/AudioEffects.cpp):
Factories : from_samples(data:y*, channels:II), tone(...), sfxr(...)
Compose : concat([...], overlap), mix([...])
Effects : pitch_shift, low_pass, high_pass, echo, reverb, distortion,
bit_crush, gain, normalize, reverse, slice, sfxr_mutate
Props : duration, sample_count, sample_rate, channels, sfxr_params
Why it bites: the AudioEffects math runs on a CPU vector<int16_t> with several
unguarded divisions by sample_rate and no NaN/inf clamping on most parameters
(echo feedback, reverb room/damping, distortion drive, lp/hp cutoff). Extreme
and special-float parameters are deliberately injected to flush out UB.
No audio device or GL context is needed -- this is pure buffer math, so it runs
in the windowless fuzz build. Contract: fuzz_one_input(data: bytes) -> None.
"""
import mcrfpy
from fuzz_common import ByteStream, EXPECTED_EXCEPTIONS, safe_reset
MAX_OPS = 24
WAVEFORMS = ("sine", "square", "saw", "triangle", "noise", "bogus", "")
SFXR_PRESETS = ("coin", "laser", "explosion", "powerup", "hurt", "jump",
"blip", "not_a_preset", "")
def weird_float(stream):
"""A float that is sometimes a special value (inf/-inf/nan) or extreme."""
sel = stream.u8() % 8
if sel == 0:
return float("inf")
if sel == 1:
return float("-inf")
if sel == 2:
return float("nan")
if sel == 3:
return 0.0
if sel == 4:
return stream.float_in_range(-1e9, 1e9)
if sel == 5:
return stream.float_in_range(-1.0, 1.0)
if sel == 6:
return -stream.float_in_range(0.0, 1e6)
return stream.float_in_range(0.0, 1000.0)
def weird_int(stream):
sel = stream.u8() % 5
if sel == 0:
return 0
if sel == 1:
return -stream.int_in_range(0, 1_000_000)
if sel == 2:
return stream.int_in_range(0, 32)
if sel == 3:
return stream.int_in_range(0, 1_000_000)
return stream.int_in_range(-5, 20)
def make_from_samples(stream):
"""Build a SoundBuffer from raw fuzzer bytes interpreted as int16 PCM."""
n = stream.int_in_range(0, 4096)
raw = stream.take(n)
channels = stream.int_in_range(0, 4) # 0 -> ValueError (guarded)
rate = stream.pick_one((0, 1, 8000, 22050, 44100, 48000, 96000))
return mcrfpy.SoundBuffer.from_samples(raw, channels, rate)
def make_tone(stream):
freq = weird_float(stream)
dur = stream.float_in_range(0.0, 0.2) # keep buffers small/fast
wave = stream.pick_one(WAVEFORMS)
return mcrfpy.SoundBuffer.tone(
freq, dur, wave,
stream.float_in_range(-0.1, 0.5), # attack
stream.float_in_range(-0.1, 0.5), # decay
stream.float_in_range(-0.5, 2.0), # sustain
stream.float_in_range(-0.1, 0.5), # release
stream.pick_one((0, 1, 8000, 44100)), # sample_rate
)
def make_sfxr(stream):
if stream.bool():
return mcrfpy.SoundBuffer.sfxr(stream.pick_one(SFXR_PRESETS),
stream.u32() if stream.bool() else None)
# Custom-parameter mode: 24 floats, several pushed to extremes.
params = tuple(weird_float(stream) for _ in range(24))
return mcrfpy.SoundBuffer.sfxr(None, None, *params)
FACTORIES = (make_from_samples, make_tone, make_sfxr)
def make_buffer(stream):
"""Return a SoundBuffer or None (never raises out)."""
factory = stream.pick_one(FACTORIES)
try:
return factory(stream)
except EXPECTED_EXCEPTIONS:
return None
def apply_effect(stream, buf):
"""Apply one randomly chosen effect, returning the (possibly new) buffer."""
which = stream.u8() % 12
try:
if which == 0:
return buf.pitch_shift(weird_float(stream))
if which == 1:
return buf.low_pass(weird_float(stream))
if which == 2:
return buf.high_pass(weird_float(stream))
if which == 3:
return buf.echo(weird_float(stream), weird_float(stream), weird_float(stream))
if which == 4:
return buf.reverb(weird_float(stream), weird_float(stream), weird_float(stream))
if which == 5:
return buf.distortion(weird_float(stream))
if which == 6:
return buf.bit_crush(weird_int(stream), weird_int(stream))
if which == 7:
return buf.gain(weird_float(stream))
if which == 8:
return buf.normalize()
if which == 9:
return buf.reverse()
if which == 10:
return buf.slice(weird_float(stream), weird_float(stream))
return buf.sfxr_mutate(weird_float(stream),
stream.u32() if stream.bool() else None)
except EXPECTED_EXCEPTIONS:
return buf
def read_props(buf):
for name in ("duration", "sample_count", "sample_rate", "channels", "sfxr_params"):
try:
_ = getattr(buf, name)
except EXPECTED_EXCEPTIONS:
pass
def fuzz_compose(stream, pool):
"""Exercise concat/mix over a list of accumulated buffers."""
if not pool:
return None
k = stream.int_in_range(0, len(pool))
chosen = [pool[stream.int_in_range(0, len(pool) - 1)] for _ in range(k)]
try:
if stream.bool():
return mcrfpy.SoundBuffer.concat(chosen, weird_float(stream))
return mcrfpy.SoundBuffer.mix(chosen)
except EXPECTED_EXCEPTIONS:
return None
def fuzz_one_input(data):
stream = ByteStream(data)
pool = []
try:
n = stream.int_in_range(1, MAX_OPS)
for _ in range(n):
if stream.remaining < 1:
break
choice = stream.u8() % 5
if choice == 0 or not pool:
buf = make_buffer(stream)
if buf is not None:
read_props(buf)
if len(pool) < 6:
pool.append(buf)
elif choice == 1:
buf = fuzz_compose(stream, pool)
if buf is not None and len(pool) < 6:
pool.append(buf)
else:
# Apply a chain of effects to an existing buffer.
buf = pool[stream.int_in_range(0, len(pool) - 1)]
chain = stream.int_in_range(1, 4)
for _ in range(chain):
buf = apply_effect(stream, buf)
if buf is None:
break
if buf is not None:
read_props(buf)
except EXPECTED_EXCEPTIONS:
pass

View file

@ -0,0 +1,184 @@
"""fuzz_import_parsers - Tiled/LDtk external file parser fuzzing (#312, Tier A).
External file parsers are historically the highest-yield fuzz target. This
drives the three loaders that ingest untrusted XML/JSON from disk:
mcrfpy.TileSetFile(path) -- .tsx (XML) / .tsj / .json
mcrfpy.TileMapFile(path) -- .tmx (XML) / .tmj / .json
mcrfpy.LdtkProject(path) -- .ldtk (JSON)
All three take a filesystem PATH (verified: src/tiled/PyTileSetFile.cpp:20,
src/tiled/PyTileMapFile.cpp:22, src/ldtk/PyLdtkProject.cpp:24 -- each parses
"s"). So each iteration writes the fuzzer bytes to a temp file with a
format-appropriate extension, then loads it.
Input layout: byte[0] selects the loader/extension (see LOADERS); the rest is
the file body. Seeds are built as bytes([selector]) + <fixture file bytes> so
libFuzzer mutates real .tsx/.tmx/.tmj/.ldtk content (see tests/fuzz/seeds/).
After a successful load the parsed object's properties, lookups, and dynamic
IntEnum builders (terrain_enum) are exercised -- the underlying parse code has
unguarded std::stoi on wangid tokens (src/tiled/TiledParse.cpp:200-202) and
divisions that depend on parsed grid_size/tile_size (src/ldtk/LdtkParse.cpp).
Contract: fuzz_one_input(data: bytes) -> None.
"""
import os
import mcrfpy
from fuzz_common import ByteStream, EXPECTED_EXCEPTIONS, safe_reset
# The loaders wrap std::exceptions (malformed file, unresolvable external
# tileset reference, etc.) in PyExc_IOError == OSError, which the shared
# EXPECTED_EXCEPTIONS tuple does not include. Swallow it here: a parse failure
# on garbage input is the expected outcome, not a bug.
PARSER_EXPECTED = EXPECTED_EXCEPTIONS + (OSError,)
# (extension, loader-callable). byte[0] % len(LOADERS) selects one.
LOADERS = (
("tsx", lambda p: mcrfpy.TileSetFile(p)),
("tmx", lambda p: mcrfpy.TileMapFile(p)),
("tmj", lambda p: mcrfpy.TileMapFile(p)),
("ldtk", lambda p: mcrfpy.LdtkProject(p)),
("tsj", lambda p: mcrfpy.TileSetFile(p)),
("json", lambda p: mcrfpy.TileMapFile(p)),
)
_TMP_DIR = os.environ.get("TMPDIR", "/tmp")
_TMP_BASE = os.path.join(_TMP_DIR, "mcrf_fuzz_parser_%d" % os.getpid())
def _read_seq(obj, names):
"""Read a list of property names, swallowing expected errors."""
for name in names:
try:
_ = getattr(obj, name)
except EXPECTED_EXCEPTIONS:
pass
def exercise_tileset(stream, ts):
_read_seq(ts, ("name", "tile_width", "tile_height", "tile_count", "columns",
"margin", "spacing", "image_source", "properties", "wang_sets"))
# tile_info over a few ids including out-of-range
for _ in range(stream.int_in_range(0, 4)):
try:
ts.tile_info(stream.int_in_range(-5, 4096))
except EXPECTED_EXCEPTIONS:
pass
# Walk wang sets; terrain_enum builds a Python IntEnum from parsed colors.
try:
for ws in ts.wang_sets:
_read_seq(ws, ("name", "type", "color_count", "colors"))
try:
ws.terrain_enum()
except EXPECTED_EXCEPTIONS:
pass
except EXPECTED_EXCEPTIONS:
pass
def exercise_tilemap(stream, tm):
_read_seq(tm, ("width", "height", "tile_width", "tile_height", "orientation",
"properties", "tileset_count", "tile_layer_names",
"object_layer_names"))
try:
for i in range(min(tm.tileset_count, 4)):
try:
tm.tileset(i)
except EXPECTED_EXCEPTIONS:
pass
except EXPECTED_EXCEPTIONS:
pass
try:
for name in list(tm.tile_layer_names)[:3]:
try:
tm.tile_layer_data(name)
except EXPECTED_EXCEPTIONS:
pass
except EXPECTED_EXCEPTIONS:
pass
for _ in range(stream.int_in_range(0, 4)):
try:
tm.resolve_gid(stream.int_in_range(-5, 1 << 20))
except EXPECTED_EXCEPTIONS:
pass
try:
for name in list(tm.object_layer_names)[:3]:
try:
tm.object_layer(name)
except EXPECTED_EXCEPTIONS:
pass
except EXPECTED_EXCEPTIONS:
pass
def exercise_ldtk(stream, proj):
_read_seq(proj, ("version", "tileset_names", "ruleset_names", "level_names",
"enums"))
try:
for name in list(proj.tileset_names)[:3]:
try:
proj.tileset(name)
except EXPECTED_EXCEPTIONS:
pass
except EXPECTED_EXCEPTIONS:
pass
try:
for name in list(proj.ruleset_names)[:3]:
try:
rs = proj.ruleset(name)
_read_seq(rs, ("name", "grid_size", "value_count", "values",
"rule_count", "group_count"))
try:
rs.terrain_enum()
except EXPECTED_EXCEPTIONS:
pass
except EXPECTED_EXCEPTIONS:
pass
except EXPECTED_EXCEPTIONS:
pass
try:
for name in list(proj.level_names)[:3]:
try:
proj.level(name)
except EXPECTED_EXCEPTIONS:
pass
except EXPECTED_EXCEPTIONS:
pass
EXERCISE = {
"tsx": exercise_tileset,
"tsj": exercise_tileset,
"tmx": exercise_tilemap,
"tmj": exercise_tilemap,
"json": exercise_tilemap,
"ldtk": exercise_ldtk,
}
def fuzz_one_input(data):
stream = ByteStream(data)
if stream.remaining < 1:
return
ext, loader = LOADERS[stream.u8() % len(LOADERS)]
body = stream.take(stream.remaining)
path = "%s.%s" % (_TMP_BASE, ext)
try:
with open(path, "wb") as fh:
fh.write(body)
except OSError:
return
try:
obj = loader(path)
EXERCISE[ext](stream, obj)
except PARSER_EXPECTED:
pass
finally:
try:
os.unlink(path)
except OSError:
pass

View file

@ -0,0 +1,203 @@
"""fuzz_shader_bindings - shader uniform binding lifetime fuzzing (#312, Tier A).
Targets the exact pattern that produced #270 / #271 / #277: uniform bindings and
the UniformCollection are lifetime-coupled to a Drawable via weak_ptr, and the
binding/collection can outlive the Drawable. This hammers create -> bind ->
destroy-target -> evaluate sequences.
Surface (verified against src/PyUniformBinding.cpp, src/PyUniformCollection.cpp,
src/PyShader.cpp, src/UIDrawable.cpp):
drawable.uniforms -> UniformCollection (weak_ptr owner)
uniforms[name] = float | (x,y[,z[,w]]) | PropertyBinding | CallableBinding
PropertyBinding(target, property) -- weak_ptr<UIDrawable> target
CallableBinding(callable) -- Python callable, evaluated lazily
drawable.shader = Shader(frag_src) -- requires GL; degrades gracefully
If sf::Shader::isAvailable() is false in the windowless fuzz build, Shader()
raises RuntimeError (PyShader.cpp:82) which is swallowed -- but the binding /
UniformCollection bookkeeping (where the bugs lived) runs regardless of whether
a GLSL program actually compiles. Drawables are kept DETACHED (never appended to
a scene) so that `del` drops the last shared_ptr and the weak_ptr safety paths
are actually taken.
Contract: fuzz_one_input(data: bytes) -> None.
"""
import mcrfpy
from fuzz_common import ByteStream, EXPECTED_EXCEPTIONS, safe_reset
MAX_OPS = 24
# Animatable float properties shared widely enough to satisfy hasProperty()
# on most drawables; invalid pairings raise ValueError and are swallowed.
PROP_NAMES = ("x", "y", "opacity", "w", "h", "z_index", "radius", "thickness",
"rotation", "outline", "not_a_real_property")
GOOD_FRAG = (
"uniform float u; void main(){ gl_FragColor = vec4(u,0.0,0.0,1.0); }"
)
BAD_FRAG = "this is not glsl {{{"
def make_drawable(stream):
"""Construct one DETACHED drawable (not added to any scene)."""
which = stream.u8() % 7
try:
if which == 0:
return mcrfpy.Frame(pos=(0, 0), size=(10, 10))
if which == 1:
return mcrfpy.Caption((0, 0), None, "x")
if which == 2:
return mcrfpy.Sprite(pos=(0, 0))
if which == 3:
return mcrfpy.Grid(grid_size=(4, 4))
if which == 4:
return mcrfpy.Line(start=(0, 0), end=(5, 5))
if which == 5:
return mcrfpy.Circle(center=(0, 0), radius=4.0)
return mcrfpy.Arc(center=(0, 0), radius=4.0, start_angle=0.0, end_angle=90.0)
except EXPECTED_EXCEPTIONS:
return None
def uniform_value(stream, target):
"""Return a value to assign into a uniform slot."""
sel = stream.u8() % 7
if sel == 0:
return stream.float_in_range(-1e6, 1e6)
if sel == 1:
return (stream.float_in_range(-1, 1), stream.float_in_range(-1, 1))
if sel == 2:
return tuple(stream.float_in_range(-1, 1) for _ in range(3))
if sel == 3:
return tuple(stream.float_in_range(-1, 1) for _ in range(4))
if sel == 4 and target is not None:
# PropertyBinding back to the target drawable (or another one).
return ("propbind", stream.pick_one(PROP_NAMES))
if sel == 5:
return ("callbind", stream.u8() % 3)
# Deliberately wrong shapes.
return stream.pick_one((None, "str", (), (1, 2, 3, 4, 5), {"x": 1}))
def _callable_for(kind):
if kind == 0:
return lambda: 1.0
if kind == 1:
return lambda: (_ for _ in ()).throw(ValueError("boom")) # raises on call
return lambda: "not a float" # wrong return type
def set_uniform(stream, drawable, name):
raw = uniform_value(stream, drawable)
value = raw
try:
if isinstance(raw, tuple) and len(raw) == 2 and raw[0] == "propbind":
value = mcrfpy.PropertyBinding(drawable, raw[1])
elif isinstance(raw, tuple) and len(raw) == 2 and raw[0] == "callbind":
value = mcrfpy.CallableBinding(_callable_for(raw[1]))
except EXPECTED_EXCEPTIONS:
return None
try:
drawable.uniforms[name] = value
except EXPECTED_EXCEPTIONS:
pass
return value if isinstance(value, (mcrfpy.PropertyBinding, mcrfpy.CallableBinding)) else None
def read_uniforms(drawable, names):
try:
coll = drawable.uniforms
except EXPECTED_EXCEPTIONS:
return
try:
_ = len(coll)
except (TypeError, ValueError, AttributeError):
pass
for name in names:
try:
_ = coll[name]
except EXPECTED_EXCEPTIONS:
pass
try:
for _k in coll:
pass
except EXPECTED_EXCEPTIONS:
pass
def read_binding(binding):
"""Read a binding after its target may have died (the safety check)."""
if binding is None:
return
for name in ("value", "is_valid", "target", "property", "callable"):
try:
_ = getattr(binding, name)
except EXPECTED_EXCEPTIONS:
pass
try:
repr(binding)
except EXPECTED_EXCEPTIONS:
pass
def try_assign_shader(stream, drawable):
src = GOOD_FRAG if stream.bool() else BAD_FRAG
try:
sh = mcrfpy.Shader(src, bool(stream.bool()))
except EXPECTED_EXCEPTIONS:
return
try:
drawable.shader = sh
except EXPECTED_EXCEPTIONS:
pass
def fuzz_lifecycle(stream):
"""The core lifetime pattern: bind to a target, drop it, then evaluate."""
target = make_drawable(stream)
if target is None:
return
if stream.bool():
try_assign_shader(stream, target)
bindings = []
names = []
for _ in range(stream.int_in_range(1, 5)):
name = stream.ascii_str(6) or "u"
names.append(name)
b = set_uniform(stream, target, name)
if b is not None:
bindings.append(b)
read_uniforms(target, names)
# Drop the only strong ref to the target while bindings/collection persist.
if stream.bool():
try:
coll = target.uniforms # keep the collection alive past target
except EXPECTED_EXCEPTIONS:
coll = None
del target
for b in bindings:
read_binding(b)
if coll is not None:
for name in names:
try:
_ = coll[name]
except EXPECTED_EXCEPTIONS:
pass
else:
del target
for b in bindings:
read_binding(b)
def fuzz_one_input(data):
stream = ByteStream(data)
try:
n = stream.int_in_range(1, MAX_OPS)
for _ in range(n):
if stream.remaining < 1:
break
fuzz_lifecycle(stream)
except EXPECTED_EXCEPTIONS:
pass

View file

@ -0,0 +1,164 @@
"""fuzz_texture_factory - Texture byte-ingestion + pixel-transform fuzzing
(#312, Tier B).
Raw byte ingestion and pixel transforms are classic memory-safety surfaces.
Drives the three Texture factory/transform entry points (verified against
src/PyTexture.cpp):
Texture.from_bytes(data:y*, width, height, sprite_w, sprite_h, name='') [classmethod]
Texture.composite(layers:list, sprite_w, sprite_h, name='') [classmethod]
texture.hsl_shift(hue, sat=0.0, lit=0.0) [instance]
from_bytes validates len(data) == width*height*4 (PyTexture.cpp:283) but does
NOT bound width/height to be positive, so zero dimensions and mismatched
lengths are stressed here. composite checks list/element types and equal layer
dimensions (PyTexture.cpp:315-360); empty lists, None/non-Texture elements, and
mismatched sizes are all exercised.
NOTE: the width*height*4 multiplication-overflow path (huge dimensions paired
with a coincidentally-matching tiny buffer, PyTexture.cpp:283) is intentionally
NOT probed here -- forcing it would trigger a multi-GB sf::Image allocation and
OOM the fuzzer rather than produce a clean crash. Dimensions are bounded so the
length check rejects oversized inputs before any allocation. That overflow case
warrants a separate, manual ASan check.
Creating sf::Texture needs a GL context; the fuzz build is SFML-backed (not
headless) and already loads default_texture at startup, so creation works.
Contract: fuzz_one_input(data: bytes) -> None.
"""
import mcrfpy
from fuzz_common import ByteStream, EXPECTED_EXCEPTIONS, safe_reset
MAX_OPS = 24
def weird_float(stream):
sel = stream.u8() % 6
if sel == 0:
return float("inf")
if sel == 1:
return float("-inf")
if sel == 2:
return float("nan")
if sel == 3:
return stream.float_in_range(-720.0, 720.0)
if sel == 4:
return stream.float_in_range(-5.0, 5.0)
return 0.0
def _default_texture():
try:
return getattr(mcrfpy, "default_texture", None)
except Exception:
return None
def make_valid_texture(stream):
"""Construct a small, valid Texture via from_bytes (matched length)."""
w = stream.int_in_range(1, 16)
h = stream.int_in_range(1, 16)
need = w * h * 4
raw = stream.take(need)
if len(raw) < need:
raw = raw + bytes(need - len(raw))
sw = stream.int_in_range(1, w)
sh = stream.int_in_range(1, h)
return mcrfpy.Texture.from_bytes(raw, w, h, sw, sh)
def fuzz_from_bytes_mismatch(stream):
"""Deliberately mismatched dims/length/zero dims -> error paths."""
n = stream.int_in_range(0, 1024)
raw = stream.take(n)
w = stream.int_in_range(-2, 64)
h = stream.int_in_range(-2, 64)
sw = stream.int_in_range(-2, 64)
sh = stream.int_in_range(-2, 64)
try:
mcrfpy.Texture.from_bytes(raw, w, h, sw, sh)
except EXPECTED_EXCEPTIONS:
pass
except MemoryError:
pass
def fuzz_composite(stream, pool):
"""Composite a list mixing valid textures, None, non-textures, mismatches."""
layers = []
count = stream.int_in_range(0, 5)
for _ in range(count):
sel = stream.u8() % 5
if sel == 0 and pool:
layers.append(pool[stream.int_in_range(0, len(pool) - 1)])
elif sel == 1:
layers.append(None)
elif sel == 2:
layers.append("not a texture")
elif sel == 3:
dt = _default_texture()
layers.append(dt)
else:
try:
layers.append(make_valid_texture(stream))
except EXPECTED_EXCEPTIONS:
pass
except MemoryError:
pass
sw = stream.int_in_range(-2, 32)
sh = stream.int_in_range(-2, 32)
# Sometimes pass a non-list to hit the PyList_Check path.
arg = layers if stream.bool() else tuple(layers)
try:
mcrfpy.Texture.composite(arg, sw, sh)
except EXPECTED_EXCEPTIONS:
pass
except MemoryError:
pass
def fuzz_hsl_shift(stream, pool):
tex = None
if pool and stream.bool():
tex = pool[stream.int_in_range(0, len(pool) - 1)]
else:
tex = _default_texture()
if tex is None:
return
try:
tex.hsl_shift(weird_float(stream), weird_float(stream), weird_float(stream))
except EXPECTED_EXCEPTIONS:
pass
except MemoryError:
pass
def fuzz_one_input(data):
stream = ByteStream(data)
pool = []
try:
n = stream.int_in_range(1, MAX_OPS)
for _ in range(n):
if stream.remaining < 1:
break
choice = stream.u8() % 4
if choice == 0:
try:
tex = make_valid_texture(stream)
if len(pool) < 6:
pool.append(tex)
except EXPECTED_EXCEPTIONS:
pass
except MemoryError:
pass
elif choice == 1:
fuzz_from_bytes_mismatch(stream)
elif choice == 2:
fuzz_composite(stream, pool)
else:
fuzz_hsl_shift(stream, pool)
except EXPECTED_EXCEPTIONS:
pass

View file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

View file

@ -0,0 +1,326 @@
{
"__header__": {
"fileType": "LDtk Project JSON",
"app": "LDtk",
"doc": "https://ldtk.io/json",
"schema": "https://ldtk.io/files/JSON_SCHEMA.json",
"appAuthor": "Sebastien 'deepnight' Benard",
"appVersion": "1.5.3",
"url": "https://ldtk.io"
},
"iid": "test-project-iid",
"jsonVersion": "1.5.3",
"appBuildId": 0,
"nextUid": 100,
"identifierStyle": "Capitalize",
"toc": [],
"worldLayout": "Free",
"worldGridWidth": 256,
"worldGridHeight": 256,
"defaultLevelWidth": 256,
"defaultLevelHeight": 256,
"defaultPivotX": 0,
"defaultPivotY": 0,
"defaultGridSize": 16,
"defaultEntityWidth": 16,
"defaultEntityHeight": 16,
"bgColor": "#40465B",
"defaultLevelBgColor": "#696A79",
"minifyJson": false,
"externalLevels": false,
"exportTiled": false,
"simplifiedExport": false,
"imageExportMode": "None",
"exportLevelBg": true,
"pngFilePattern": null,
"backupOnSave": false,
"backupLimit": 10,
"backupRelPath": null,
"levelNamePattern": "Level_%idx",
"tutorialDesc": null,
"customCommands": [],
"flags": [],
"defs": {
"layers": [
{
"__type": "IntGrid",
"identifier": "Terrain",
"type": "IntGrid",
"uid": 1,
"doc": null,
"uiColor": null,
"gridSize": 16,
"guideGridWid": 0,
"guideGridHei": 0,
"displayOpacity": 1,
"inactiveOpacity": 0.6,
"hideInList": false,
"hideFieldsWhenInactive": true,
"canSelectWhenInactive": true,
"renderInWorldView": true,
"pxOffsetX": 0,
"pxOffsetY": 0,
"parallaxFactorX": 0,
"parallaxFactorY": 0,
"parallaxScaling": true,
"requiredTags": [],
"excludedTags": [],
"autoTilesetDefUid": 10,
"tilesetDefUid": 10,
"tilePivotX": 0,
"tilePivotY": 0,
"biomeFieldUid": null,
"intGridValues": [
{ "value": 1, "identifier": "wall", "color": "#FFFFFF", "tile": null, "groupUid": 0 },
{ "value": 2, "identifier": "floor", "color": "#808080", "tile": null, "groupUid": 0 },
{ "value": 3, "identifier": "water", "color": "#0000FF", "tile": null, "groupUid": 0 }
],
"intGridValuesGroups": [],
"autoRuleGroups": [
{
"uid": 50,
"name": "Walls",
"color": null,
"icon": null,
"active": true,
"isOptional": false,
"rules": [
{
"uid": 51,
"active": true,
"size": 3,
"tileRectsIds": [[[0, 0]]],
"alpha": 1,
"chance": 1,
"breakOnMatch": true,
"pattern": [
0, 0, 0,
0, 1, 0,
0, 0, 0
],
"flipX": false,
"flipY": false,
"xModulo": 1,
"yModulo": 1,
"xOffset": 0,
"yOffset": 0,
"tileXOffset": 0,
"tileYOffset": 0,
"tileRandomXMin": 0,
"tileRandomXMax": 0,
"tileRandomYMin": 0,
"tileRandomYMax": 0,
"checker": "None",
"tileMode": "Single",
"pivotX": 0,
"pivotY": 0,
"outOfBoundsValue": -1,
"perlinActive": false,
"perlinSeed": 0,
"perlinScale": 0.2,
"perlinOctaves": 2,
"invalidated": false
},
{
"uid": 52,
"active": true,
"size": 3,
"tileRectsIds": [[[16, 0]]],
"alpha": 1,
"chance": 1,
"breakOnMatch": true,
"pattern": [
0, -1, 0,
0, 1, 0,
0, 0, 0
],
"flipX": true,
"flipY": false,
"xModulo": 1,
"yModulo": 1,
"xOffset": 0,
"yOffset": 0,
"tileXOffset": 0,
"tileYOffset": 0,
"tileRandomXMin": 0,
"tileRandomXMax": 0,
"tileRandomYMin": 0,
"tileRandomYMax": 0,
"checker": "None",
"tileMode": "Single",
"pivotX": 0,
"pivotY": 0,
"outOfBoundsValue": -1,
"perlinActive": false,
"perlinSeed": 0,
"perlinScale": 0.2,
"perlinOctaves": 2,
"invalidated": false
}
],
"usesWizard": false,
"requiredBiomeValues": [],
"biomeRequirementMode": 0
},
{
"uid": 60,
"name": "Floors",
"color": null,
"icon": null,
"active": true,
"isOptional": false,
"rules": [
{
"uid": 61,
"active": true,
"size": 1,
"tileRectsIds": [[[32, 0]], [[48, 0]]],
"alpha": 1,
"chance": 1,
"breakOnMatch": true,
"pattern": [2],
"flipX": false,
"flipY": false,
"xModulo": 1,
"yModulo": 1,
"xOffset": 0,
"yOffset": 0,
"tileXOffset": 0,
"tileYOffset": 0,
"tileRandomXMin": 0,
"tileRandomXMax": 0,
"tileRandomYMin": 0,
"tileRandomYMax": 0,
"checker": "None",
"tileMode": "Single",
"pivotX": 0,
"pivotY": 0,
"outOfBoundsValue": -1,
"perlinActive": false,
"perlinSeed": 0,
"perlinScale": 0.2,
"perlinOctaves": 2,
"invalidated": false
}
],
"usesWizard": false,
"requiredBiomeValues": [],
"biomeRequirementMode": 0
}
],
"autoSourceLayerDefUid": null
}
],
"entities": [],
"tilesets": [
{
"__cWid": 4,
"__cHei": 4,
"identifier": "Test_Tileset",
"uid": 10,
"relPath": "test_tileset.png",
"embedAtlas": null,
"pxWid": 64,
"pxHei": 64,
"tileGridSize": 16,
"spacing": 0,
"padding": 0,
"tags": [],
"tagsSourceEnumUid": null,
"enumTags": [],
"customData": [],
"savedSelections": [],
"cachedPixelData": null
}
],
"enums": [
{
"identifier": "TileType",
"uid": 20,
"values": [
{ "id": "Solid", "tileRect": null, "color": 0 },
{ "id": "Platform", "tileRect": null, "color": 0 }
],
"iconTilesetUid": null,
"externalRelPath": null,
"externalFileChecksum": null,
"tags": []
}
],
"externalEnums": [],
"levelFields": []
},
"levels": [
{
"identifier": "Level_0",
"iid": "level-0-iid",
"uid": 30,
"worldX": 0,
"worldY": 0,
"worldDepth": 0,
"pxWid": 80,
"pxHei": 80,
"__bgColor": "#696A79",
"bgColor": null,
"useAutoIdentifier": false,
"bgRelPath": null,
"bgPos": null,
"bgPivotX": 0.5,
"bgPivotY": 0.5,
"__smartColor": "#ADADB5",
"__bgPos": null,
"externalRelPath": null,
"fieldInstances": [],
"layerInstances": [
{
"__identifier": "Terrain",
"__type": "IntGrid",
"__cWid": 5,
"__cHei": 5,
"__gridSize": 16,
"__opacity": 1,
"__pxTotalOffsetX": 0,
"__pxTotalOffsetY": 0,
"__tilesetDefUid": 10,
"__tilesetRelPath": "test_tileset.png",
"iid": "layer-iid",
"levelId": 30,
"layerDefUid": 1,
"pxOffsetX": 0,
"pxOffsetY": 0,
"visible": true,
"optionalRules": [],
"intGridCsv": [
1, 1, 1, 1, 1,
1, 2, 2, 2, 1,
1, 2, 3, 2, 1,
1, 2, 2, 2, 1,
1, 1, 1, 1, 1
],
"autoLayerTiles": [
{ "px": [0, 0], "src": [0, 0], "f": 0, "t": 0, "d": [51], "a": 1 },
{ "px": [16, 0], "src": [0, 0], "f": 0, "t": 0, "d": [51], "a": 1 },
{ "px": [32, 0], "src": [0, 0], "f": 0, "t": 0, "d": [51], "a": 1 },
{ "px": [48, 0], "src": [0, 0], "f": 0, "t": 0, "d": [51], "a": 1 },
{ "px": [64, 0], "src": [0, 0], "f": 0, "t": 0, "d": [51], "a": 1 },
{ "px": [16, 16], "src": [32, 0], "f": 0, "t": 2, "d": [61], "a": 1 },
{ "px": [32, 16], "src": [32, 0], "f": 0, "t": 2, "d": [61], "a": 1 },
{ "px": [48, 16], "src": [32, 0], "f": 0, "t": 2, "d": [61], "a": 1 },
{ "px": [16, 32], "src": [32, 0], "f": 0, "t": 2, "d": [61], "a": 1 },
{ "px": [48, 32], "src": [32, 0], "f": 0, "t": 2, "d": [61], "a": 1 },
{ "px": [16, 48], "src": [32, 0], "f": 0, "t": 2, "d": [61], "a": 1 },
{ "px": [32, 48], "src": [32, 0], "f": 0, "t": 2, "d": [61], "a": 1 },
{ "px": [48, 48], "src": [32, 0], "f": 0, "t": 2, "d": [61], "a": 1 }
],
"seed": 1234,
"overrideTilesetUid": null,
"gridTiles": [],
"entityInstances": []
}
],
"__neighbours": []
}
],
"worlds": [],
"dummyWorldIid": "dummy-iid"
}

View file

@ -0,0 +1,83 @@
{ "compressionlevel":-1,
"height":4,
"infinite":false,
"layers":[
{
"data":[1,2,1,1,2,2,1,3,1,1,1,1,3,3,2,1],
"height":4,
"id":1,
"name":"Ground",
"opacity":1,
"type":"tilelayer",
"visible":true,
"width":4,
"x":0,
"y":0
},
{
"data":[0,0,0,0,0,9,10,0,0,11,12,0,0,0,0,0],
"height":4,
"id":2,
"name":"Overlay",
"opacity":1,
"type":"tilelayer",
"visible":true,
"width":4,
"x":0,
"y":0
},
{
"draworder":"topdown",
"id":3,
"name":"Objects",
"objects":[
{
"id":1,
"name":"spawn",
"type":"point",
"x":32,
"y":32,
"point":true,
"properties":[
{"name":"player_start", "type":"bool", "value":true}
]
},
{
"id":2,
"name":"trigger_zone",
"x":0,
"y":0,
"width":64,
"height":64,
"properties":[
{"name":"zone_id", "type":"int", "value":42}
]
}
],
"opacity":1,
"type":"objectgroup",
"visible":true,
"x":0,
"y":0
}
],
"nextlayerid":4,
"nextobjectid":3,
"orientation":"orthogonal",
"renderorder":"right-down",
"tiledversion":"1.10.2",
"tileheight":16,
"tilesets":[
{
"firstgid":1,
"source":"test_tileset.tsj"
}
],
"tilewidth":16,
"type":"map",
"version":"1.10",
"width":4,
"properties":[
{"name":"map_name", "type":"string", "value":"test"}
]
}

View file

@ -0,0 +1,36 @@
<?xml version="1.0" encoding="UTF-8"?>
<map version="1.10" tiledversion="1.10.2" orientation="orthogonal" renderorder="right-down" width="4" height="4" tilewidth="16" tileheight="16" infinite="0" nextlayerid="4" nextobjectid="3">
<properties>
<property name="map_name" value="test"/>
</properties>
<tileset firstgid="1" source="test_tileset.tsx"/>
<layer id="1" name="Ground" width="4" height="4">
<data encoding="csv">
1,2,1,1,
2,2,1,3,
1,1,1,1,
3,3,2,1
</data>
</layer>
<layer id="2" name="Overlay" width="4" height="4">
<data encoding="csv">
0,0,0,0,
0,9,10,0,
0,11,12,0,
0,0,0,0
</data>
</layer>
<objectgroup id="3" name="Objects">
<object id="1" name="spawn" type="point" x="32" y="32">
<properties>
<property name="player_start" type="bool" value="true"/>
</properties>
<point/>
</object>
<object id="2" name="trigger_zone" x="0" y="0" width="64" height="64">
<properties>
<property name="zone_id" type="int" value="42"/>
</properties>
</object>
</objectgroup>
</map>

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.