Files
splatter-tennis/assets/_famroll.py
T
wuwiandClaude Opus 5 e121df569c The reference sheet was the bug: a clipped head, five scales, frozen legs
Oliver walked the sprite bench and reported the same two faults on every family:
the healthy walk loses its head, and the wounded walk's feet do not move. Both
were in the ONE FILE every zombie tennis character is drawn from — and the family
pipeline had copied it faithfully, which is the pipeline working.

WHERE IT CAME FROM. That sheet was not composed from the sprites. It was a
GENERATED RE-DRAW of the club pro, made in nine separate 4K calls, one per row
band, then stacked (gen4k.py, in a session scratchpad, untracked and now gone).
Nine calls returned nine scales, and call 1 came back with the shuffle frame
decapitated. Measured against the sprite it was drawn from, that one figure
scaled 2.341 wide and 1.996 tall where all thirty others were proportional to
three decimals: 33 px missing off the top, width untouched. The local input to
that call was exact at 2.000x. The sheet lost the head, not the sprite.

THE FIX IS TO STOP PUTTING THE REFERENCE THROUGH THE MODEL. _famref.py composes
it from the 31 z_r* frames on disk — one scale, applied once, NEAREST — and costs
no tokens. That kills the head and the five scales together, and it explains two
things in _zsheet.py that read like fudges: the fall1 re-anchor and the walk-frame
levelling exist only to undo per-band scale drift. The levelling is also what
turned a headless z_sh2 into a headless AND 13% oversized one, by reading the
missing head as a height error and scaling up to compensate.

THE GATE WAS MISSING ON THE INPUT. _zsheet.py checks what comes BACK from the
model — band count, figure count, stacked bodies, edges, palette, heights — and
nothing had ever checked the sheet handed TO it. _famref.check() re-reads the file
it just wrote with _zsheet's own detection and refuses one where any figure's
width and height scales disagree by 2%, or where a stride pair is frozen.

THE THIRD DEFECT NEEDED NEW ART. z_rw1b had the same legs as z_rw1 (15.1% against
the healthy pair's 51.1%), so the club pro has slid through both wound stages
since the day he shipped, and every family inherited it. _stride.py draws the
missing twin, and what made it work after words had failed for as long as they
were tried is SHOWING the edit: image 2 is the healthy pair side by side, the same
character performing exactly the change being asked for. The headless twin then
took the ARM-OFF pair as its example — a wounded body is a closer picture of the
edit, the same chaining the wound ladder already uses. Its gate measures three
things and prints all three: legs moved, body above the hips did not, racket kept
its bed. The thresholds are CALIBRATED on pairs that already ship, not chosen —
my first guesses rejected z_e1/z_e2 and z_g1/z_g2, both accepted art. And a twin
FILLS the racket bed instead of keying it out: the first rolls came back with 959
px of enclosed transparency against their base's 0, which would have flickered the
racket face empty/filled once per step.

AFTER, on the installed sprites — leg difference within each stride pair, where
the wounded pairs used to sit between 4.8% and 19.7%:

  r  51.1 / 68.1 / 36.7     ru 55.9 / 59.1 / 38.7
  br 40.7 / 42.0 / 26.8     gk 41.1 / 44.6 / 30.6
  sh 59.4 / 71.4 / 43.9     lj 38.4 / 45.5 / 27.4
  bb 56.5 / 67.1 / 43.2     st 51.6 / 66.1 / 35.7

All seven families re-rolled off the rebuilt reference, every play row with its
head, and all 24 family/wound-stage states alternating a real pair — checked by
instrumenting drawImage on the bench, not by eye. br and lj sit lowest on the
headless pair and are worth a re-roll if they ever read as stiff; lj needed one,
its first roll came back frozen on all three pairs with the headless row 31 px
short.

AND THE REFERENCE IS TRACKED NOW. It lived in gitignored _refcheck/, so the thing
the entire pipeline rests on was unreproducible for as long as it existed. It is
assets/_pose_ref_family.png, next to _pose_ref_serve.png, and the scripts that
build and consume it are in the repo instead of a scratchpad.

Also in here: the SHAMBLE BOB, which was a 1 px lift on a wall clock applied to
every body every frame whether it was moving or not — a held pose bobbed forever
and a corpse bobbed while it lay on the ground. It only happens while walking now,
and it steps off ground covered, which is the same rule the strides follow.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 22:45:05 +02:00

126 lines
7.3 KiB
Python

# _famroll.py — roll ONE opponent family: 31 sprites, one call, one character.
#
# uv run --with pillow --with requests python assets/_famroll.py br # one family
# uv run --with pillow --with requests python assets/_famroll.py sh bb # several, in sequence
#
# Then cut it back down, review first and install second:
# uv run --with pillow --with numpy python assets/_zsheet.py assets/_refcheck/fam_br.png br --out assets/_refcheck/sprites_br
# uv run --with pillow --with numpy python assets/_zsheet.py assets/_refcheck/fam_br.png br --install
#
# WHY THIS IS IN THE REPO. Every family in ZFAMS except the club pro was rolled by a script that lived
# in a session scratchpad and is gone, off a reference in a gitignored directory. The pipeline PROJECT.md
# calls the one reliable way to make this game's art was, in practice, unreproducible. This is that
# script, tracked, reading the tracked reference that `_famref.py` composes.
#
# IMAGE 1 IS THE POSES, IMAGE 2 IS THE CHARACTER, and naming each image's job in the prompt is
# load-bearing: without it the majority wins and the roll comes back as whatever most of the pixels
# look like. See PROJECT.md, "What the model will and will not do".
import base64, os, sys
import requests
from PIL import Image
HERE = os.path.dirname(os.path.abspath(__file__))
REF = os.path.join(HERE, '_pose_ref_family.png')
SCRATCH = os.path.join(HERE, '_refcheck')
MODEL = os.environ.get('SPLAT_MODEL', 'google/gemini-3-pro-image')
# key -> (the frame the character is read off, the words that describe it). The frame is the source of
# truth for build and colour; the words only have to stop the model averaging it into a generic zombie.
FAMS = {
'br': ('z_g1', 'an ENORMOUS FAT ZOMBIE BRUTE: a huge sagging belly over the belt, a filthy pale '
'grey-green work shirt straining over it, heavy olive-brown work trousers, thick '
'tree-trunk legs, scuffed brown boots, and a small bald head sunk low into massive '
'rounded shoulders. Far bulkier, heavier and wider than the figure in image 1'),
'sh': ('z_a1', 'a lean SHAMBLING ZOMBIE in a filthy dark red tennis shirt and grey shorts, gaunt and stooped'),
'bb': ('z_e1', 'a small young ZOMBIE BALL BOY in a pale tournament polo and shorts, slight and short'),
'st': ('z_d1', 'a ZOMBIE STEWARD in a dark uniform jacket with a high collar, stiff and official'),
'ru': ('z_f1', 'a wiry FAST ZOMBIE RUNNER in a sleeveless tennis top, lean and forward-leaning'),
'gk': ('z_boss1', 'a heavy ZOMBIE GROUNDSKEEPER in stained work overalls and a shapeless hat'),
'lj': ('z_judge1', 'a ZOMBIE LINE JUDGE in a dark blazer and pale trousers, upright and severe'),
}
def key():
if os.environ.get('OPENROUTER_API_KEY'): return os.environ['OPENROUTER_API_KEY']
for line in open(os.path.join(HERE, '..', '.env')):
if line.startswith('OPENROUTER_API_KEY='):
return line.split('=', 1)[1].strip().strip('"').strip("'")
sys.exit('no OPENROUTER_API_KEY')
def char_ref(k, spr):
"""IMAGE 2: the character, at 4x NEAREST on magenta. Raw sprite pixels, no round trip — the same
rule the pose sheet now follows, and for the same reason."""
im = Image.open(os.path.join(HERE, spr + '.png')).convert('RGBA')
big = im.resize((im.width * 4, im.height * 4), Image.NEAREST)
pad = round(big.height * 0.08)
out = Image.new('RGB', (big.width + 2 * pad, big.height + 2 * pad), (255, 0, 255))
out.paste(big, (pad, pad), big)
p = os.path.join(SCRATCH, f'char_{k}.png')
os.makedirs(SCRATCH, exist_ok=True)
out.save(p)
return p
def prompt(who):
return (
'Take IMAGE 1 and redraw it with ONE change: the character.\n\n'
'IMAGE 1 is a sprite sheet of a zombie tennis player: 31 figures in 9 rows on a flat magenta '
'background, first undamaged, then with one arm torn off, then with the head gone.\n\n'
f'IMAGE 2 is THE CHARACTER to draw instead: {who}. It is a walking corpse with dead '
'grey-green skin. Copy its build, its garments and every one of its colours exactly.\n\n'
'Keep IMAGE 1 EXACTLY as it is in every other respect: the same 9 rows, the same number of '
'figures per row, the same order, the same poses, the same facing, the same sizes, the same '
'damage states in the same places. A figure with no head keeps no head; a figure missing an arm '
'keeps that arm missing with the same stump and blood. Only the body wearing the poses changes. '
'Do not carry over the previous character\'s shirt, shorts, shoes, hair or headband.\n\n'
# EVERY UNDAMAGED FIGURE HAS A HEAD, said out loud. The clause above ("a figure with no head
# keeps no head") is there for the wound rows and the old normalisation pass took it as licence
# to remove one from the play row — which is how six families came back with a decapitated
# shuffle frame. The reference no longer shows that; the prompt no longer implies it either.
'EVERY FIGURE IN THE FIRST THREE ROWS HAS A HEAD ON ITS SHOULDERS, and so does every figure in '
'rows 4, 5 and 6. Only the last three rows are headless. Never remove a head that image 1 shows.\n\n'
'MATCH THE DRAWING STYLE OF IMAGE 1 EXACTLY. Image 1 is hard-edged pixel art and your output '
'must be indistinguishable from it in technique: the same square pixel grid at the same pixel '
'size, the same hard black outline around every figure, the same flat blocks of colour, the '
'same stepped aliased edges, the same hand-placed dithering. No soft brushwork, no airbrushing, '
'no blur, no gradients, no anti-aliasing.\n\n'
'The background is a solid, flat, uniform chroma key. Use EXACTLY hex color #FF00FF, one single '
'flat tone across the whole canvas behind every figure, with no panels or boxes of any other '
'colour. The inside of every racket head is that same EXACT #FF00FF. No text, no shadows, '
'no ground lines.')
def u(p):
return 'data:image/png;base64,' + base64.b64encode(open(p, 'rb').read()).decode()
def roll(k):
spr, who = FAMS[k]
ch = char_ref(k, spr)
r = requests.post('https://openrouter.ai/api/v1/images',
headers={'Authorization': f'Bearer {key()}', 'Content-Type': 'application/json'},
# 4K and 9:16: the endpoint DEFAULTS to 1K and snaps the aspect to an enum, so both are
# spelled out. The reference is composed at 9:16 for exactly this reason.
json={'model': MODEL, 'prompt': prompt(who), 'resolution': '4K', 'aspect_ratio': '9:16',
'input_references': [{'type': 'image_url', 'image_url': {'url': u(REF)}},
{'type': 'image_url', 'image_url': {'url': u(ch)}}]},
timeout=1800)
if r.status_code != 200:
print(f'{k}: HTTP {r.status_code} {r.text[:200]}'); return
d = r.json().get('data') or []
if not d:
print(f'{k}: no image returned'); return
b = d[0].get('b64_json') or d[0].get('image_url', {}).get('url', '').split(',')[-1]
out = os.path.join(SCRATCH, f'fam_{k}.png')
open(out, 'wb').write(base64.b64decode(b))
print(f'{k}: {out} {Image.open(out).size} (character from {spr})', flush=True)
if __name__ == '__main__':
keys = [a for a in sys.argv[1:] if not a.startswith('-')]
if not keys: sys.exit(f'usage: _famroll.py <key>... keys: {" ".join(FAMS)}')
for k in keys:
if k not in FAMS: sys.exit(f'unknown family {k}')
roll(k)