Skip to content

Code review P4: legibility — contributor docs, import-time side effects documented, dedup, dead code, line endings#161

Open
HansGR wants to merge 17 commits into
ff6wc:devfrom
HansGR:code-review-p4-legibility
Open

Code review P4: legibility — contributor docs, import-time side effects documented, dedup, dead code, line endings#161
HansGR wants to merge 17 commits into
ff6wc:devfrom
HansGR:code-review-p4-legibility

Conversation

@HansGR

@HansGR HansGR commented Jun 13, 2026

Copy link
Copy Markdown

Fourth of five stacked code-review PRs (performed by Anthropic Fable 5). Based on the P3 PR — merge P1–P3 first. This tier's own commits are 2a8cf0d..3a2e6b0.

What's included (7 commits)

  • 2a8cf0d — docstrings for the import-time magic (comments only). The three least discoverable mechanisms now explain themselves: args/ parses sys.argv and injects attributes into its own namespace at import time; log/ performs file I/O at import time; objectives/ replaces itself in sys.modules with an Objectives instance. Also documented: Space's class-level shared state, its two-pass label resolution, the instruction-callable expansion, and the event loader's class-naming convention (including that a mismatched class name is silently skipped).
  • 04a4c5aCONTRIBUTING.md (human-facing companion to llms.md/agents.md): requirements (Python 3.9+), the seed-build pipeline phase by phase, the two cardinal rules (RNG call order is part of the seed format; flags are never removed/renamed), how to add a flag module / event / objective condition, and style conventions. README links it.
  • 85b6956constants/battle_addresses.py: the enemy-level table 0x3b18 was independently hardcoded in four places across three files; now one named constant. The constants live in constants/ rather than battle/ deliberately: importing anything from the battle package executes battle/__init__.py, whose imports perform ROM writes at import time — an import from data/ would run those writes during the data phase and shift every later dynamic allocation (~15K bytes of pointer changes; we hit exactly this during development and verified the fix).
  • c7891d7 — objective condition dedup: _battle_condition.py and _menu_condition.py were identical except for one word in their space descriptions. Implementation now lives once in _asm_condition.py; the two modules subclass it setting only condition_type. The variants remain distinct classes deliberately — _CachedFunction caches written routines per class, so merging them would change the ROM layout.
  • 10ccb1c — sprite rendering dedup: rgb_data/write_ppm/get_ppm each computed the same dimensions and RGB matrix; extracted into one _pose_rgb helper, with rendering tests.
  • c0bc8c7 — dead code sweep: commented-out blocks removed (imperial_base.py, espers.py, args/items.py); unused imports removed (kefka_tower.py, starting_gold_items.py); name() added to the two flag modules missing it; import sys hoisted out of error handlers; "averaage" → "average" in two help strings (user-facing help/metadata text only).
  • 3a2e6b0 — line endings: 26 files had CRLF endings; all normalized to LF (verified content-identical with git diff --ignore-cr-at-eol), and .gitattributes now enforces LF for source and marks .pal/.bin graphics data binary.

Seed compatibility

Verified byte-identical ROMs (SHA-256) against the pre-change baseline for four flagsets: default, 20-flag heavy, a 4-objective flagset (exercising the deduplicated battle/menu condition classes), and a shuffle flagset. Spoiler logs identical.

https://claude.ai/code/session_01QnF26DB2TYi1hpJE8L6EJr

claude added 17 commits June 12, 2026 21:11
…e overflow corruption

Priority 1 items from the v1.4.3 code review:

- .gitignore: exclude *.smc/*.sfc ROM files (legal requirement) and
  generated test artifacts in tests/
- CI (.github/workflows/ci.yml): syntax check, unit tests, and wc.py -h
  smoke test on Python 3.9-3.13, plus a guard job that fails if any
  ROM-like or oversized file is ever committed
- tests/: new unit test suite (65 tests) covering the ROM-independent
  core: heap allocation/free/reserve, label and branch encoding,
  Space write/clear/conflict handling, compression round-trips,
  flatten/intersection/weighted_random/shuffle_if, seeding, ROM
  validation, and CLI parsing
- memory/space.py: validate bounds BEFORE writing in Space.write() so
  an overflowing write can no longer corrupt bytes beyond the end of
  its space; replace bare except clauses with except TypeError
- memory/errors.py: new RomSpaceError (subclasses MemoryError for
  backward compatibility) raised by Space.write() and Heap.allocate()
  instead of the builtin MemoryError
- agents.md / llms.md: updated error signature and test documentation

No RNG calls are added, removed, or reordered: seed output is unchanged.

https://claude.ai/code/session_01QnF26DB2TYi1hpJE8L6EJr
The setter stored (value - MAX_RUN_SUCCESS), a negative number, while the
getter computes (MAX_RUN_SUCCESS - raw). A set/get round trip returned the
wrong value and the negative raw value would corrupt the bit-packed byte at
init_data[21] on write.

Latent bug: nothing calls the setter today, so generated seeds are
unaffected; the first feature to use it would have silently corrupted
character init data. Regression tests added.

https://claude.ai/code/session_01QnF26DB2TYi1hpJE8L6EJr
The assertion message referenced bare suplex_train_quest_name instead of
cls.suplex_train_quest_name, so a failed lookup raised NameError instead of
the intended diagnostic. Use an explicit RuntimeError so the check also
survives python -O.

https://claude.ai/code/session_01QnF26DB2TYi1hpJE8L6EJr
Delete an unreachable 'return opts' (opts was never defined) after the
options() return statement, and the first of two definitions of
_format_spells_log_entries, which was silently shadowed by the second.
No behavior change: the surviving definition is the one that already ran.

https://claude.ai/code/session_01QnF26DB2TYi1hpJE8L6EJr
Reward.type is initialized to Python None and RewardType.NONE is never
assigned anywhere, so the early-return could never trigger. By the time
mod() runs, all rewards have been assigned a real type.

https://claude.ai/code/session_01QnF26DB2TYi1hpJE8L6EJr
…, scaling description

- utils/compression.py: compress() printed an error and returned data with
  a truncated size header when output exceeded 65535 bytes, which would
  silently corrupt whatever consumed it; raise ValueError instead
  (regression test added)
- metadata/flag_metadata_writer.py: 'if type(group_title):' was always
  true (a type object is truthy); replace with an explicit str/None/callable
  dispatch that preserves the existing manifest output exactly
- battle/scaling.py: the xp/gp scaling routine was labeled 'scale hp/mp'
  in its space description (debug/error output only, not ROM content)

https://claude.ai/code/session_01QnF26DB2TYi1hpJE8L6EJr
…tion paths

Asserts are stripped under python -O and give users a bare AssertionError
when they fire. Converted to explicit checks raising RuntimeError/ValueError
with context, and to parser.error() for user input validation:

- event/event_reward.py: the documented gating-deadlock failure in
  choose_reward() now reports which reward types were requested
- event/events.py: the CHARACTER_ESPER_ONLY_REWARDS count validation
- memory/space.py: clear() size mismatch now reports both sizes
- instruction/field/instructions.py: Set/ClearEventBit range checks
  (also reject negative bits, which would corrupt the opcode)
- args/starting_party.py: duplicate/too-many start characters now produce
  a clean CLI error like other arg modules instead of AssertionError

No control-flow change when checks pass; seed output unchanged.

https://claude.ai/code/session_01QnF26DB2TYi1hpJE8L6EJr
Three rejection-sampling loops could spin forever if their progress
assumption broke (all remaining candidates already hold the next
item/spell, or no gated slot is unlockable):

- data/espers.py shuffle_spells and data/shops.py shuffle: raise after
  10000 consecutive failed picks or when the candidate pool empties
- event/events.py character_gating_mod: raise a descriptive error when no
  unfilled character slot is unlocked (previously crashed with TypeError)
- utils/truncated_discrete_distribution.py: convert unbounded recursion to
  a capped loop; impossible bounds now raise ValueError (test added)

Guards consume no RNG and the success paths are unchanged, so seed
output is identical.

https://claude.ai/code/session_01QnF26DB2TYi1hpJE8L6EJr
- starting_gold_items.py: int() conversion failures are ValueError
- misc_magic/espers/scaling/lores/items menu(): .replace() on a non-string
  entry value raises AttributeError; catching everything could hide real
  bugs in menu generation

(misc_magic.py has CRLF line endings, preserved as-is.)

https://claude.ai/code/session_01QnF26DB2TYi1hpJE8L6EJr
- graphics/tools/png_{portrait,sprite}.py import PIL, contradicting the
  documented 'standard library only' requirement; a missing install now
  produces a clear message instead of a bare ImportError. agents.md
  updated to document the optional dependency.
- log/__init__.py: spoiler log encoding was platform-dependent (cp1252 on
  Windows); now explicitly utf-8.

https://claude.ai/code/session_01QnF26DB2TYi1hpJE8L6EJr
The three least discoverable mechanisms in the codebase now have module
docstrings: args/ parses sys.argv and injects attributes at import time,
log/ performs file I/O at import time, and objectives/ replaces itself in
sys.modules with an Objectives instance. Also documented: Space's
class-level shared state and its two-pass label resolution, the
instruction-callable expansion, and the event loader's class-naming
convention (including the silent-skip behavior for mismatched names).

Comments/docstrings only; no code change.

https://claude.ai/code/session_01QnF26DB2TYi1hpJE8L6EJr
Human-facing companion to llms.md/agents.md: requirements, how a seed is
built phase by phase, the seed-reproducibility and flags-are-forever
rules, how to add a flag/event/objective, and style conventions. README
now links it and states the Python version requirement.

https://claude.ai/code/session_01QnF26DB2TYi1hpJE8L6EJr
0x3b18 (enemy level table) was independently hardcoded in battle/scaling.py,
battle/load_enemy_level.py, and twice in data/enemy_script_custom_commands.py.
New constants/battle_addresses.py is the single source of truth for it and
the per-battle scale-level variables (0x3ecc-0x3ece).

The constants live in constants/ rather than battle/ deliberately: importing
anything from the battle package executes battle/__init__.py, whose imports
perform rom writes at import time. An import from data/ would run those
writes during the data phase and shift every later dynamic allocation
(~15k bytes of pointer changes). constants/ is side-effect free.

https://claude.ai/code/session_01QnF26DB2TYi1hpJE8L6EJr
_battle_condition.py and _menu_condition.py were identical except for the
word battle/menu in their space descriptions. The implementation now lives
once in _asm_condition.py; the two modules subclass it setting only
condition_type. The variants remain distinct classes deliberately:
_CachedFunction caches written routines per class, so merging them would
change the rom layout. Space descriptions are preserved exactly.

https://claude.ai/code/session_01QnF26DB2TYi1hpJE8L6EJr
rgb_data, write_ppm, and get_ppm each computed the same dimensions and rgb
matrix; extracted into one _pose_rgb helper. Also removes the two unused
'import graphics.poses' statements. Rendering tests added.

https://claude.ai/code/session_01QnF26DB2TYi1hpJE8L6EJr
- remove commented-out code: imperial_base.py branch alternatives (and the
  constant only they referenced), espers.py alternative Raiden dialog,
  items.py disabled log loop
- remove unused imports: kefka_tower.py 'import args' (events use
  self.args), starting_gold_items.py 'import random'
- args/graphics.py and args/objectives.py now define name() like the other
  31 flag modules, used at their existing section() calls
- objectives.py: hoist 'import sys' out of the two error handlers
- scaling.py: fix 'averaage' -> 'average' in two help strings (user-facing
  help/metadata text only; flag values and seed output unaffected)

https://claude.ai/code/session_01QnF26DB2TYi1hpJE8L6EJr
26 files had CRLF line endings (verified content-identical with
git diff --ignore-cr-at-eol). .gitattributes now enforces LF for source
and marks .pal/.bin graphics data binary so it is never diffed or
normalized.

https://claude.ai/code/session_01QnF26DB2TYi1hpJE8L6EJr

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a comprehensive suite of unit tests, replaces several assertions with robust runtime exceptions, refactors shared assembly-based objective conditions, and fixes multiple bugs, such as character run success bit-packing. The code review feedback focuses on improving robustness and performance: it highlights a bug in truncated_discrete_distribution where falsy bounds (like 0) are ignored, suggests upfront validation of minimum/maximum bounds, recommends deterministic deadlock detection using all() instead of retry counters in shuffle_spells and shop shuffling, and advises optimizing list intersection complexity to O(N + M) by utilizing a set.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment on lines +12 to +15
if minimum and result < minimum:
continue
if maximum and result > maximum:
continue

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

In Python, 0 is falsy. If minimum or maximum is passed as 0, the checks if minimum and if maximum will evaluate to False, causing the bounds to be ignored. Use explicit is not None checks instead.

Suggested change
if minimum and result < minimum:
continue
if maximum and result > maximum:
continue
if minimum is not None and result < minimum:
continue
if maximum is not None and result > maximum:
continue

Comment on lines 2 to 3
def truncated_discrete_distribution(mean, stddev, minimum = None, maximum = None):
import random

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

If minimum is greater than maximum, the range is impossible, and the loop will run for MAX_ATTEMPTS (10,000 iterations) before raising a ValueError. Validating this upfront avoids unnecessary CPU cycles.

Suggested change
def truncated_discrete_distribution(mean, stddev, minimum = None, maximum = None):
import random
def truncated_discrete_distribution(mean, stddev, minimum = None, maximum = None):
if minimum is not None and maximum is not None and minimum > maximum:
raise ValueError(f"truncated_discrete_distribution: minimum ({minimum}) cannot be greater than maximum ({maximum})")
import random

Comment thread data/espers.py
Comment on lines +106 to +125
stalled_picks = 0
MAX_STALLED_PICKS = 10000

while len(spells) > 0:
if not esper_indices or stalled_picks > MAX_STALLED_PICKS:
raise RuntimeError(f"shuffle_spells: cannot place {len(spells)} remaining spells, "
f"{len(esper_indices)} espers have open slots")
esper_index = random.choice(esper_indices)
esper = self.espers[esper_index]
if not esper.has_spell(spells[-1].id):
stalled_picks = 0
spell = spells.pop()
if self.args.esper_spells_shuffle_random_rates:
esper.add_spell(spell.id, random.choice(Esper.LEARN_RATES))
else:
esper.add_spell(spell.id, spell.rate)
if esper.spell_count == spell_counts[esper_index]:
esper_indices.remove(esper_index)
else:
stalled_picks += 1

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Instead of using a retry counter (stalled_picks) and a hard limit of 10,000 attempts, we can deterministically check if all available espers already know the spell using all(). This avoids wasting RNG calls and CPU cycles on futile random selections when a deadlock occurs.

Suggested change
stalled_picks = 0
MAX_STALLED_PICKS = 10000
while len(spells) > 0:
if not esper_indices or stalled_picks > MAX_STALLED_PICKS:
raise RuntimeError(f"shuffle_spells: cannot place {len(spells)} remaining spells, "
f"{len(esper_indices)} espers have open slots")
esper_index = random.choice(esper_indices)
esper = self.espers[esper_index]
if not esper.has_spell(spells[-1].id):
stalled_picks = 0
spell = spells.pop()
if self.args.esper_spells_shuffle_random_rates:
esper.add_spell(spell.id, random.choice(Esper.LEARN_RATES))
else:
esper.add_spell(spell.id, spell.rate)
if esper.spell_count == spell_counts[esper_index]:
esper_indices.remove(esper_index)
else:
stalled_picks += 1
while len(spells) > 0:
if not esper_indices:
raise RuntimeError(f"shuffle_spells: cannot place {len(spells)} remaining spells, no espers have open slots")
if all(self.espers[i].has_spell(spells[-1].id) for i in esper_indices):
raise RuntimeError(f"shuffle_spells: cannot place {len(spells)} remaining spells, all {len(esper_indices)} espers with open slots already know {spells[-1].id}")
esper_index = random.choice(esper_indices)
esper = self.espers[esper_index]
if not esper.has_spell(spells[-1].id):
spell = spells.pop()
if self.args.esper_spells_shuffle_random_rates:
esper.add_spell(spell.id, random.choice(Esper.LEARN_RATES))
else:
esper.add_spell(spell.id, spell.rate)
if esper.spell_count == spell_counts[esper_index]:
esper_indices.remove(esper_index)

Comment thread data/shops.py
Comment on lines +75 to +91
stalled_picks = 0
MAX_STALLED_PICKS = 10000

while len(items) > 0:
if not shop_indices or stalled_picks > MAX_STALLED_PICKS:
raise RuntimeError(f"shops shuffle: cannot place {len(items)} remaining items, "
f"{len(shop_indices)} shops have open slots")
shop_index = random.choice(shop_indices)
shop = type_shops[shop_type][shop_index]
if not shop.contains(items[-1]):
stalled_picks = 0
item = items.pop()
shop.append(item)
if shop.item_count == item_counts[shop_index]:
shop_indices.remove(shop_index)
else:
stalled_picks += 1

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Similar to the esper spell shuffling, we can deterministically check if all available shops already stock the item using all(). This eliminates the need for stalled_picks and avoids wasting RNG calls.

Suggested change
stalled_picks = 0
MAX_STALLED_PICKS = 10000
while len(items) > 0:
if not shop_indices or stalled_picks > MAX_STALLED_PICKS:
raise RuntimeError(f"shops shuffle: cannot place {len(items)} remaining items, "
f"{len(shop_indices)} shops have open slots")
shop_index = random.choice(shop_indices)
shop = type_shops[shop_type][shop_index]
if not shop.contains(items[-1]):
stalled_picks = 0
item = items.pop()
shop.append(item)
if shop.item_count == item_counts[shop_index]:
shop_indices.remove(shop_index)
else:
stalled_picks += 1
while len(items) > 0:
if not shop_indices:
raise RuntimeError(f"shops shuffle: cannot place {len(items)} remaining items, no shops have open slots")
if all(type_shops[shop_type][i].contains(items[-1]) for i in shop_indices):
raise RuntimeError(f"shops shuffle: cannot place {len(items)} remaining items, all {len(shop_indices)} shops with open slots already stock {items[-1]}")
shop_index = random.choice(shop_indices)
shop = type_shops[shop_type][shop_index]
if not shop.contains(items[-1]):
item = items.pop()
shop.append(item)
if shop.item_count == item_counts[shop_index]:
shop_indices.remove(shop_index)

Comment thread utils/intersection.py
Comment on lines +3 to 5
def intersection(lst1, lst2):
lst3 = [value for value in lst1 if value in lst2]
return lst3 No newline at end of file

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Converting lst2 to a set first improves the lookup complexity from O(M) to O(1) per element, reducing the overall complexity of the intersection from O(N * M) to O(N + M). This is a standard performance best practice in Python.

Suggested change
def intersection(lst1, lst2):
lst3 = [value for value in lst1 if value in lst2]
return lst3
def intersection(lst1, lst2):
set2 = set(lst2)
return [value for value in lst1 if value in set2]

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants