diff --git a/PROJECT.md b/PROJECT.md
index fb382ba..91df6db 100644
--- a/PROJECT.md
+++ b/PROJECT.md
@@ -20,7 +20,9 @@ anything, so if you add a `return` for an awkward deal, check the body still run
the state-machine contracts that have historically produced the show-stoppers. The Dockerfile runs
it as a build stage, so a red suite cannot deploy — but finding out from Coolify is the slow way.
-Last updated 2026-08-18 (the store model in LATER revised: free download + €2.99 unlock-everything
+Last updated 2026-08-20 (the friend across the net gets his OWN sheets — the player's kit, drawn
+from the front, so his pick dyes the same garment yours does; the setup screen shows that front too.
+See "THE FRIEND ACROSS THE NET WEARS YOUR KIT". Before that 2026-08-18: the store model in LATER revised: free download + €2.99 unlock-everything
IAP with the free-tier gates, superseding paid-upfront; before that 2026-08-15, two sessions: the
finetuning pass — gore opens in the gore, the menu theme
comes back, one world board per mode, and the barrel economy cut — over the batch below: six new
@@ -2291,6 +2293,7 @@ it is the WRONG tool, superseded by the reference-sheet flow. Route by job:
| Rebuild the pose reference | `_famref.py` — composed from disk, no API call, gated. |
| A PLAYER RUN CYCLE | `_vidroll.py` — a video model, not a still one. See "THE RUN CYCLES CAME OUT OF A VIDEO" below. `_runroll.py` still rolls the single frame you film FROM, and still serves the opponents' pairs; it cannot produce a gait. |
| A stride twin (legs frozen) | `_stride.py`. |
+| THE SAME CHARACTER IN SOMEONE ELSE'S CLOTHES, every pose at once | `_frontroll.py` — famroll's trick pointed at the tennis poses: image 1 is the ten `o_*` frames composed from disk, image 2 is the body to dress them in. Two calls made the whole netplay family. Ten `_regen` batches would have been ten chances for the man to drift. |
| Single-sheet remaster, boss roster, any non-family sprite | `_regen.py` + `_regen_batches.json` — the only jobs it is still for. |
| Standalone art (logo, panels) | `_key.py`; coarse legacy sheets, `_slice.py`. |
@@ -2533,6 +2536,52 @@ The gates, and the numbers they were calibrated on:
**SUPERSEDED 2026-08-12 — see the next section.** Those 31 frames were not art: `--verify` found
27 of them dead. The brute is now live (`ZFAMS` has `'br'`, the KEEPER wears it) off ONE sheet.
+## THE FRIEND ACROSS THE NET WEARS YOUR KIT, FROM THE FRONT (2026-08-20)
+
+Oliver, on a netplay match: *"the opponent color is displayed correctly, but the shirt is colored not
+the trousers (men) and for women it is not the whole dress"* — and his own diagnosis was the right
+one: **we did not have the player character from the front.** The friend was drawn on the ROSTER
+opponent's sheets (`o_*`: red polo, dark grey shorts) and dyed on a third hue arc, `TINT.oKit`.
+
+**Red is the one hue a kit cannot own on this art, and no arc could have fixed it.** Both halves
+were measured before anything was generated:
+
+- On the men's sheet the only saturated red is the SHIRT — the shorts are neutral grey at
+ saturation 0.05, so a hue rotation can never reach them. Your own pick dyes your SHORTS. Two
+ people in one match, wearing their colour on different garments.
+- On the women's sheet the red top sits at the same hue as her SKIN and her HAIR (top h≈0 and 336-355,
+ shaded skin h 13-24, and the arc's own floor is satKit 0.15). Rendering the shipped tint with the
+ ACID pick put green blotches down her arms, her legs and her ponytail. That was live, and nobody
+ had looked at it.
+
+**The repaint was tried first and it fails, which is worth knowing.** Mapping the red kit to white
+and the grey shorts to blue offline — no API, no new art — gives a genuinely good MAN (white polo,
+blue shorts, right shoes) and a ruined WOMAN: the same hue overlap eats her hair, her face and her
+leg shading. `_famref`'s `bed_magenta` is also a trap on this art — it repaints the largest PALE
+blob, which is the racket bed on the z_r* master but a SHOE here, so `_frontroll.open_bed()` asks
+whether the bed is already keyed out before touching a frame.
+
+So the friend got his own sheets: `n_*`, the player's kit drawn from the FRONT, tinted through the
+PLAYER's arcs and the tour's own mode — his shorts dye, her whites wash, exactly as yours do.
+`TINT.oKit` is deleted. The roster CPU keeps `o_*` and stays as drawn; it is not a person.
+
+**Two rolls made all twenty sprites, and the method is the reason.** `_frontroll.py` is `_famroll`'s
+trick applied to tennis: IMAGE 1 is the POSES (the ten installed `o_*` frames composed from disk at
+one scale, 4/4/2, gated for band and figure count before a call is made), IMAGE 2 is the CHARACTER
+(`p_idle1`, from behind — the model is told outright that it is inventing the face). `_zsheet.cut()`
+takes a `want`/`level` pair now so a non-zombie family can reuse the cutter; `level` names the frame
+groups that must come back the same height (the idle pair, the bounce pair, the stride pair — the
+serve toss and the smash are meant to be taller and are left alone). Heights came back within 1 px
+of the `o_*` originals they were composed from.
+
+The one thing the first roll got wrong: it drew a TENNIS BALL into both bouncing frames. The game
+draws the ball; a drawn one is a blob stuck to his hand. The prompt says so now.
+
+**And the SETUP SCREEN uses the front too** (Oliver, same session: *"das heisst aber auch, dass wir
+in der farb- und charakterauswahl den frontfacing nehmen können"*). Picking a face off the back of a
+head was the best the art allowed; `drawLook` draws `n_idle1` now — same kit, same palette, same
+tint pass, so it still cannot drift from what walks on.
+
## THE RUN CYCLES CAME OUT OF A VIDEO — the still models cannot draw a gait (2026-08-15)
**A ROW OF POSES IS NOT A WALK CYCLE, and six sessions of prompting could not make it one.** Asked
diff --git a/assets/_frontroll.py b/assets/_frontroll.py
new file mode 100644
index 0000000..65acc29
--- /dev/null
+++ b/assets/_frontroll.py
@@ -0,0 +1,202 @@
+# _frontroll.py — the NETPLAY OPPONENT: the player's own kit, drawn from the FRONT.
+#
+# uv run --with pillow --with numpy --with requests python assets/_frontroll.py m --ref
+# uv run --with pillow --with numpy --with requests python assets/_frontroll.py m
+# uv run --with pillow --with numpy python assets/_frontroll.py m --cut # review
+# uv run --with pillow --with numpy python assets/_frontroll.py m --cut --install
+#
+# WHY THIS EXISTS. In a netplay match the body across the net is your FRIEND, and it was drawn on
+# the roster opponent's sheets: red polo, dark grey shorts, red headband. His pick then dyed the
+# only thing on that art that carries a hue — the SHIRT — while your own pick dyes your SHORTS,
+# because the player's kit is white-over-blue. Two people in one match, wearing their colour on
+# different garments. Worse on the women's sheets: the opponent's red sits at the same hue as her
+# skin and her hair, so `TINT.oKit` bled green over her arms, her legs and her ponytail.
+#
+# Neither is fixable in the tint pass — the roster opponent's art simply has no hue-isolated kit —
+# so the friend gets his own sheets, `n_*`, in the PLAYER'S kit: white shirt, blue shorts, and all
+# whites for the women's tour, which is exactly what `TINT.kit`/`wash` are measured against.
+#
+# THE METHOD IS `_famroll.py`'s, and it is the reason this is two calls and not ten. IMAGE 1 is the
+# POSES (the installed o_* frames, composed from disk at one scale), IMAGE 2 is the CHARACTER (the
+# player, from behind) — "redraw image 1 with one change: the character". Ten separate _regen
+# batches would be ten chances for the man to drift; the whole family in one roll cannot drift.
+import base64, os, sys
+import numpy as np
+import requests
+from PIL import Image, ImageDraw
+
+sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
+from _famref import bed_magenta # the racket bed has to key out — see _zsheet
+from _zsheet import cut
+
+HERE = os.path.dirname(os.path.abspath(__file__))
+SCRATCH = os.path.join(HERE, '_refcheck')
+MODEL = os.environ.get('SPLAT_MODEL', 'google/gemini-3-pro-image')
+MAGENTA = (255, 0, 255)
+SIZE = (4304, 5380) # 4:5, the enum value nearest this sheet's own aspect
+MARGIN, GAP = 0.025, 60
+
+# 4 / 4 / 2, which lands within a hair of 4:5. The pairs that MUST come back the same height sit
+# next to each other so the levelling below reads them off one row.
+ROWS = [['idle1', 'idle2', 'bounce', 'bounce2'],
+ ['swing1', 'swing2', 'toss', 'smash'],
+ ['run1', 'run2']]
+# ...and these are the ones the game flips between at speed: a 2 px height difference in a pair is
+# a body that hops once per bounce or once per stride (the lesson _zsheet's walk levelling records).
+# The serve toss and the smash are NOT levelled — they are meant to be taller.
+LEVEL = [['idle1', 'idle2'], ['bounce', 'bounce2'], ['run1', 'run2']]
+
+WHO = {
+ '': ('a male tennis player in a PURE WHITE polo shirt, ROYAL BLUE shorts, a WHITE cloth headband, '
+ 'white socks and white tennis shoes, short blond hair, lean and athletic. His shirt is WHITE, '
+ 'never red. His shorts are BRIGHT ROYAL BLUE, never grey, never black, never navy. His '
+ 'headband and his socks are WHITE, never red'),
+ '_f': ('a female tennis player dressed head to hem in TENNIS WHITES: a PURE WHITE sleeveless-cut '
+ 'tennis top and a PURE WHITE pleated tennis skirt, white socks and white tennis shoes, dark '
+ 'hair tied back in a ponytail. Her top is WHITE, never red. Her skirt is WHITE, never dark '
+ 'grey and never black. Every garment she wears is white'),
+}
+
+
+def open_bed(im):
+ """Transparent pixels the border cannot reach — i.e. the racket's string bed, already keyed out.
+
+ `bed_magenta` repaints the largest PALE blob, which is right for the z_r* master (its beds are
+ painted) and wrong for most of these: with the bed already open the largest pale blob is a SHOE,
+ and the reference went out with a magenta boot on it. Ask first.
+ """
+ a = np.array(im.convert('RGBA'))[..., 3] > 40
+ m = Image.new('L', (im.width + 2, im.height + 2), 0)
+ m.paste(Image.fromarray((a * 255).astype('uint8')), (1, 1))
+ ImageDraw.floodfill(m, (0, 0), 128)
+ return int((np.array(m)[1:-1, 1:-1] == 0).sum())
+
+
+def compose(sfx):
+ """IMAGE 1: the ten tennis poses, composed from the installed sprites at ONE scale. No API call."""
+ frames = {}
+ for r in ROWS:
+ for n in r:
+ im = Image.open(os.path.join(HERE, f'o_{n}{sfx}.png')).convert('RGBA')
+ frames[n] = im if open_bed(im) > 100 else bed_magenta(im)
+ row_w = [sum(frames[n].width for n in r) + GAP * (len(r) - 1) for r in ROWS]
+ row_h = [max(frames[n].height for n in r) for r in ROWS]
+ cw, ch = max(row_w), sum(row_h) + GAP * (len(ROWS) - 1)
+ k = min(SIZE[0] * (1 - 2 * MARGIN) / cw, SIZE[1] * (1 - 2 * MARGIN) / ch)
+ sheet = Image.new('RGB', SIZE, MAGENTA)
+ y = round((SIZE[1] - ch * k) / 2)
+ for row, rh in zip(ROWS, row_h):
+ scaled = [(n, round(frames[n].width * k), round(frames[n].height * k)) for n in row]
+ x = round((SIZE[0] - (sum(w for _, w, _ in scaled) + round(GAP * k) * (len(row) - 1))) / 2)
+ ground = y + round(rh * k) # one ground line per band, bottom-aligned
+ for n, w, h in scaled:
+ im = frames[n].resize((w, h), Image.NEAREST)
+ sheet.paste(im, (x, ground - h), im)
+ x += w + round(GAP * k)
+ y = ground + round(GAP * k)
+ p = os.path.join(SCRATCH, f'front_ref{sfx or "_m"}.png')
+ os.makedirs(SCRATCH, exist_ok=True)
+ sheet.save(p)
+ print(f'{p} {sheet.size} scale {k:.3f} 10 figures, {len(ROWS)} bands')
+ return p
+
+
+def char_ref(sfx):
+ """IMAGE 2: the player, at 4x NEAREST on magenta. Raw sprite pixels — the famroll rule."""
+ im = Image.open(os.path.join(HERE, f'p_idle1{sfx}.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), MAGENTA)
+ out.paste(big, (pad, pad), big)
+ p = os.path.join(SCRATCH, f'front_char{sfx or "_m"}.png')
+ out.save(p)
+ return p
+
+
+def prompt(sfx):
+ her = sfx == '_f'
+ return (
+ 'Take IMAGE 1 and redraw it with ONE change: the person wearing the poses.\n\n'
+ 'IMAGE 1 is a sprite sheet of a tennis player on a flat magenta background: TEN figures in '
+ 'THREE rows — a waiting pair, a ball-bouncing pair, a forehand backswing and follow-through, a '
+ 'serve toss, a smash, and a running pair.\n\n'
+ f'IMAGE 2 is THE PERSON to draw instead: {WHO[sfx]}. Copy that build and every one of those kit '
+ 'colours exactly. IMAGE 2 shows this person FROM BEHIND; every figure you draw is seen FROM '
+ 'THE FRONT, facing the viewer, exactly as image 1\'s figures do. You are inventing the face.\n\n'
+ 'Keep IMAGE 1 EXACTLY as it is in every other respect: the same 3 rows, the same number of '
+ 'figures per row, the same order, the same poses, the same facing, the same sizes, the same '
+ 'ground lines. Only the person changes. Do NOT carry over image 1\'s RED shirt, RED headband, '
+ 'RED socks or DARK GREY shorts — no red and no dark grey garment appears anywhere in your '
+ 'output.\n\n'
+ # the bounce pair is one drawing with the hand moved; the game flips between them at 3 Hz and
+ # any second difference reads as a flicker. Image 1 already has this property — say so.
+ 'The two BALL-BOUNCING figures at the end of row 1 are the SAME drawing twice and differ ONLY '
+ 'in the height of the free hand. Keep them identical in every other pixel — same stance, same '
+ 'legs, same head, same racket, same size — exactly as image 1 has them.\n\n'
+ # the game draws the ball itself, and a drawn one lands in the sprite as a blob stuck to his
+ # hand. Image 1 has none; the first roll added one to both bouncing frames anyway.
+ 'THERE IS NO TENNIS BALL ANYWHERE IN THE PICTURE. Do not draw a ball in any figure, not in the '
+ 'bouncing frames, not in the serve, not in the air, not on the ground. No motion lines.\n\n'
+ 'EVERY FIGURE STILL HOLDS THE TENNIS RACKET image 1 gives it, in the same hand, in the same '
+ 'frames — the full racket with its head, its strings, its throat and its grip.\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 dark 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'
+ # rule 15: the keyer deletes every pixel where red AND blue both beat green
+ 'NOTHING in any figure is pink, magenta, violet or purple.\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.'
+ + (' Her skin is warm and human — it is never white and never grey; only her CLOTHES are white.'
+ if her else ''))
+
+
+def key_env():
+ 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 u(p):
+ return 'data:image/png;base64,' + base64.b64encode(open(p, 'rb').read()).decode()
+
+
+def roll(sfx):
+ ref, ch = compose(sfx), char_ref(sfx)
+ r = requests.post('https://openrouter.ai/api/v1/images',
+ headers={'Authorization': f'Bearer {key_env()}', 'Content-Type': 'application/json'},
+ json={'model': MODEL, 'prompt': prompt(sfx), 'resolution': '4K', 'aspect_ratio': '4:5',
+ '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:
+ sys.exit(f'HTTP {r.status_code} {r.text[:300]}')
+ d = r.json().get('data') or []
+ if not d:
+ sys.exit('no image returned')
+ b = d[0].get('b64_json') or d[0].get('image_url', {}).get('url', '').split(',')[-1]
+ out = sheet_path(sfx)
+ open(out, 'wb').write(base64.b64decode(b))
+ print(f'{out} {Image.open(out).size}', flush=True)
+
+
+def sheet_path(sfx):
+ return os.path.join(SCRATCH, f'front{sfx or "_m"}.png')
+
+
+if __name__ == '__main__':
+ sfx = {'m': '', 'w': '_f'}.get((sys.argv[1:2] or [''])[0])
+ if sfx is None: sys.exit('usage: _frontroll.py m|w [--ref] [--cut [--install]]')
+ if '--cut' in sys.argv:
+ want = [[f'n_{n}{sfx}' for n in r] for r in ROWS]
+ level = [[f'n_{n}{sfx}' for n in g] for g in LEVEL]
+ out = HERE if '--install' in sys.argv else os.path.join(SCRATCH, f'front_cut{sfx or "_m"}')
+ cut(sheet_path(sfx), None, out, want=want, level=level)
+ elif '--ref' in sys.argv:
+ compose(sfx); char_ref(sfx)
+ else:
+ roll(sfx)
diff --git a/assets/_zsheet.py b/assets/_zsheet.py
index bccc63e..8fb103e 100644
--- a/assets/_zsheet.py
+++ b/assets/_zsheet.py
@@ -56,8 +56,11 @@ def bands(a):
return out
-def cut(sheet, fam, outdir):
- want = layout(fam)
+def cut(sheet, fam, outdir, want=None, level=None):
+ # `want`/`level` let a NON-zombie family reuse this cutter: the netplay opponent's ten tennis
+ # poses are not nine rows of walk/wound (see _frontroll.py). `level` names the frame groups that
+ # must come back the same height, which is what the zombie branch below works out by name.
+ want = want or layout(fam)
src = Image.open(sheet).convert('RGB')
a = key(src)
# THE ENCLOSED MAGENTA IS THE RACKET, AND HERE IT IS MEANT TO GO. _fine.py repaints magenta that
@@ -105,8 +108,14 @@ def cut(sheet, fam, outdir):
# racket family's, at 224/224) gets a factor of 1.0 and is untouched — which is the check that
# this rule is the right one rather than a fudge for one bad sheet.
mult = [1.0] * len(crops)
+ if level is not None:
+ at = {n: j for j, n in enumerate(flat)}
+ for grp in level:
+ h0 = crops[at[grp[0]]].shape[0]
+ for n in grp[1:]:
+ if crops[at[n]].shape[0]: mult[at[n]] = h0 / crops[at[n]].shape[0]
i = 0
- for row in want:
+ for row in want if level is None else []:
if row[0].endswith('fall1') and crops[i].shape[0]:
k_row = crops[0].shape[0] / crops[i].shape[0]
for j in range(i, i + len(row)):
diff --git a/assets/n_bounce.png b/assets/n_bounce.png
new file mode 100644
index 0000000..524ab15
Binary files /dev/null and b/assets/n_bounce.png differ
diff --git a/assets/n_bounce2.png b/assets/n_bounce2.png
new file mode 100644
index 0000000..211fd59
Binary files /dev/null and b/assets/n_bounce2.png differ
diff --git a/assets/n_bounce2_f.png b/assets/n_bounce2_f.png
new file mode 100644
index 0000000..b61bdcd
Binary files /dev/null and b/assets/n_bounce2_f.png differ
diff --git a/assets/n_bounce_f.png b/assets/n_bounce_f.png
new file mode 100644
index 0000000..cc2dfda
Binary files /dev/null and b/assets/n_bounce_f.png differ
diff --git a/assets/n_idle1.png b/assets/n_idle1.png
new file mode 100644
index 0000000..9f605a8
Binary files /dev/null and b/assets/n_idle1.png differ
diff --git a/assets/n_idle1_f.png b/assets/n_idle1_f.png
new file mode 100644
index 0000000..53402f2
Binary files /dev/null and b/assets/n_idle1_f.png differ
diff --git a/assets/n_idle2.png b/assets/n_idle2.png
new file mode 100644
index 0000000..ed3b559
Binary files /dev/null and b/assets/n_idle2.png differ
diff --git a/assets/n_idle2_f.png b/assets/n_idle2_f.png
new file mode 100644
index 0000000..3b715ed
Binary files /dev/null and b/assets/n_idle2_f.png differ
diff --git a/assets/n_run1.png b/assets/n_run1.png
new file mode 100644
index 0000000..d1e1974
Binary files /dev/null and b/assets/n_run1.png differ
diff --git a/assets/n_run1_f.png b/assets/n_run1_f.png
new file mode 100644
index 0000000..90e171f
Binary files /dev/null and b/assets/n_run1_f.png differ
diff --git a/assets/n_run2.png b/assets/n_run2.png
new file mode 100644
index 0000000..5f1b8c8
Binary files /dev/null and b/assets/n_run2.png differ
diff --git a/assets/n_run2_f.png b/assets/n_run2_f.png
new file mode 100644
index 0000000..c566d3f
Binary files /dev/null and b/assets/n_run2_f.png differ
diff --git a/assets/n_smash.png b/assets/n_smash.png
new file mode 100644
index 0000000..8d512a8
Binary files /dev/null and b/assets/n_smash.png differ
diff --git a/assets/n_smash_f.png b/assets/n_smash_f.png
new file mode 100644
index 0000000..73ba6cb
Binary files /dev/null and b/assets/n_smash_f.png differ
diff --git a/assets/n_swing1.png b/assets/n_swing1.png
new file mode 100644
index 0000000..2148fc7
Binary files /dev/null and b/assets/n_swing1.png differ
diff --git a/assets/n_swing1_f.png b/assets/n_swing1_f.png
new file mode 100644
index 0000000..99b3811
Binary files /dev/null and b/assets/n_swing1_f.png differ
diff --git a/assets/n_swing2.png b/assets/n_swing2.png
new file mode 100644
index 0000000..7d11eb4
Binary files /dev/null and b/assets/n_swing2.png differ
diff --git a/assets/n_swing2_f.png b/assets/n_swing2_f.png
new file mode 100644
index 0000000..f9c742f
Binary files /dev/null and b/assets/n_swing2_f.png differ
diff --git a/assets/n_toss.png b/assets/n_toss.png
new file mode 100644
index 0000000..f7d772d
Binary files /dev/null and b/assets/n_toss.png differ
diff --git a/assets/n_toss_f.png b/assets/n_toss_f.png
new file mode 100644
index 0000000..32740fa
Binary files /dev/null and b/assets/n_toss_f.png differ
diff --git a/index.html b/index.html
index 7defda3..06fbb60 100644
--- a/index.html
+++ b/index.html
@@ -659,6 +659,6 @@ if (location.hostname !== 'splatter-tennis.wuwi.one') {
-
+