diff --git a/defects/renpy-0001/patch/renpy-0001.patch b/defects/renpy-0001/patch/renpy-0001.patch new file mode 100644 index 000000000..6c9b95814 --- /dev/null +++ b/defects/renpy-0001/patch/renpy-0001.patch @@ -0,0 +1,35 @@ +# UNDF: UNDF-2026-000000969 +--- a/renpy/display/image.py ++++ b/renpy/display/image.py +@@ -980,10 +980,10 @@ + defaults = f(name) + + # The list of attributes a matching image may have. +- optional = list(defaults) if defaults else [] ++ optional = set(defaults) if defaults else set() + + # The list of attributes a matching image must have. +- required = [] ++ required = set() + + for i in name[1:]: + if i[0] == "-": +@@ -991,16 +991,16 @@ + + if i in optional: +- optional.remove(i) ++ optional.discard(i) + + if i in required: +- required.remove(i) ++ required.discard(i) + + else: +- required.append(i) ++ required.add(i) + + return self.choose_image(nametag, required, optional, name) + + def choose_image(self, tag, required, optional, exception_name): + # The longest length of an image that matches. + max_len = -1 diff --git a/defects/renpy-0001/test/renpy-0001-test.py b/defects/renpy-0001/test/renpy-0001-test.py new file mode 100644 index 000000000..9da02dc25 --- /dev/null +++ b/defects/renpy-0001/test/renpy-0001-test.py @@ -0,0 +1,119 @@ +#!/usr/bin/env python3 +""" +Unit test for renpy-0001: ShownImageInfo.choose_image required/optional list +membership O(I*A*(R+O)) -> set membership O(I*A) + +Defect: In renpy/display/image.py, ShownImageInfo.apply_attributes() builds +`required` and `optional` as Python lists, then passes them to choose_image(). +Inside choose_image(), the inner loop checks `i in required` and `i in optional` +for every attribute of every registered image for a tag. With lists, each `in` +check is O(N), making the total O(I * A * (R + O)) where I = images per tag, +A = attributes per image, R = len(required), O = len(optional). + +Fix: Change `required` and `optional` from lists to sets. Membership test +becomes O(1), total becomes O(I * A). Use set.add/discard instead of +list.append/remove. + +File: renpy/display/image.py +Method: ShownImageInfo.apply_attributes / ShownImageInfo.choose_image +Lines: 984-1002 (apply_attributes), 1011-1032 (choose_image inner loop) +""" + +import time + + +def simulate_defective(image_attrs_by_tag, required_list, optional_list): + """ + Simulates choose_image with required and optional as lists. + O(I * A * (R + O)) due to list membership checks. + """ + matches = [] + for attrs in image_attrs_by_tag: + if not all((i in required_list) or (i in optional_list) for i in attrs): + continue + num_required = 0 + for i in attrs: + if i in required_list: + num_required += 1 + if num_required != len(required_list): + continue + matches.append(attrs) + return matches + + +def simulate_fixed(image_attrs_by_tag, required_set, optional_set): + """ + Simulates choose_image with required and optional as sets. + O(I * A) due to set membership checks. + """ + matches = [] + for attrs in image_attrs_by_tag: + if not all((i in required_set) or (i in optional_set) for i in attrs): + continue + num_required = 0 + for i in attrs: + if i in required_set: + num_required += 1 + if num_required != len(required_set): + continue + matches.append(attrs) + return matches + + +def main(): + # Simulate a character tag with many image variants. + # Visual novel characters can have dozens of emotion/pose/outfit combos. + num_images = 200 + attrs_per_image = 8 + num_required = 30 + num_optional = 30 + + # Build a pool of attribute names + all_attrs = [f"attr{i}" for i in range(num_required + num_optional + attrs_per_image)] + + required_list = all_attrs[:num_required] + optional_list = all_attrs[num_required:num_required + num_optional] + + required_set = set(required_list) + optional_set = set(optional_list) + + # Build image attribute tuples. Each image has some required, some optional, + # and some unique attrs. + image_attrs_by_tag = [] + for img_idx in range(num_images): + attrs = tuple( + required_list[img_idx % num_required:img_idx % num_required + 3] + + optional_list[img_idx % num_optional:img_idx % num_optional + 3] + + [f"unique{img_idx}_{j}" for j in range(attrs_per_image - 6)] + ) + image_attrs_by_tag.append(attrs) + + # Correctness check + result_defective = simulate_defective(image_attrs_by_tag, required_list, optional_list) + result_fixed = simulate_fixed(image_attrs_by_tag, required_set, optional_set) + assert result_defective == result_fixed, ( + f"Results differ: defective={len(result_defective)} fixed={len(result_fixed)}" + ) + print(f"PASS correctness: both return {len(result_defective)} matches") + + # Benchmark + iterations = 2000 + + start = time.perf_counter() + for _ in range(iterations): + simulate_defective(image_attrs_by_tag, required_list, optional_list) + defective_time = time.perf_counter() - start + + start = time.perf_counter() + for _ in range(iterations): + simulate_fixed(image_attrs_by_tag, required_set, optional_set) + fixed_time = time.perf_counter() - start + + ratio = defective_time / fixed_time if fixed_time > 0 else float("inf") + print(f"PASS benchmark: defective={defective_time:.3f}s fixed={fixed_time:.3f}s ratio={ratio:.1f}x") + assert ratio > 2.0, f"Expected at least 2x improvement, got {ratio:.1f}x" + print(f"PASS ratio > 2x confirmed ({ratio:.1f}x)") + + +if __name__ == "__main__": + main()