From b973d8cde31f86b757c752b8a477993805627ead Mon Sep 17 00:00:00 2001 From: Sylpheed RE agent Date: Fri, 28 Aug 2026 23:38:37 +0000 Subject: [PATCH] re(ui): scale 0 means collapsed, not "unset" -- stop drawing it full size blit() and fill_quad() both opened with let sx_pct = if kf.scale_x == 0 { 100 } else { kf.scale_x }; so an element whose pose is collapsed to nothing rendered at FULL SIZE. I first described this as "a 1-pixel sliver", reading the .max(1) in the size arithmetic two lines below -- the guard above it meant .max(1) never saw a zero. Read the whole function, not the lines you went looking for. Control run before the change: if 0 meant "unset", something on the disc would use it throughout. Nothing does. elements with a keyframe group 15 493 at least one keyframe at scale 0 2 166 EVERY keyframe at scale 0 0 zero is a transient the element grows out of 1 762 ptlogo_eff3.t32 runs 0% -> 200%. An "unset" marker no element ever uses throughout is not a marker. Both functions now return without drawing when either scale is 0. Reach: 24 of 24 renders byte-identical across GP_TITLE (all 16 builds), GP_PAUSE_MENU and GP_OPTIONS -- additive on every screen the port needs. 195 elements have a guessed rest pose at scale 0 and 126 of those have a non-zero alpha, so the old code painted them; all 126 are in GP_READY_ROOM.pak, which S1 already declared a no-go. They are tactical-map sprites at sx=0/sy=100, mid-horizontal-wipe, that the coercion drew full-width. So: a real correctness fix with no visible effect on the menu port. Suite green, 122 passed / 0 failed across 3 suites at commit time. --- crates/sylpheed-formats/src/ui_layout.rs | 19 ++++-- docs/port/HANDOFF.md | 9 +++ docs/re/METHOD.md | 11 ++++ docs/re/REFUTED.md | 5 ++ docs/re/data/zero-scale-census.txt | 24 ++++++++ docs/re/structures/ui-rat-layout.md | 45 ++++++++++++++ tools/re-capture/zero_scale_census.py | 78 ++++++++++++++++++++++++ 7 files changed, 187 insertions(+), 4 deletions(-) create mode 100644 docs/re/data/zero-scale-census.txt create mode 100644 tools/re-capture/zero_scale_census.py diff --git a/crates/sylpheed-formats/src/ui_layout.rs b/crates/sylpheed-formats/src/ui_layout.rs index d375450d..17b86b4f 100644 --- a/crates/sylpheed-formats/src/ui_layout.rs +++ b/crates/sylpheed-formats/src/ui_layout.rs @@ -982,8 +982,11 @@ fn fill_quad( if sw == 0 || sh == 0 { return false; } - let sx_pct = if kf.scale_x == 0 { 100 } else { kf.scale_x }; - let sy_pct = if kf.scale_y == 0 { 100 } else { kf.scale_y }; + // `scale = 0` is COLLAPSED TO NOTHING, not "unset" — see `blit`. + let (sx_pct, sy_pct) = (kf.scale_x, kf.scale_y); + if sx_pct == 0 || sy_pct == 0 { + return false; + } let dw = (sw * sx_pct / 100).max(1); let dh = (sh * sy_pct / 100).max(1); let ox = kf.x - (pivot_x as i32 * (sx_pct as i32 - 100)) / 100; @@ -1040,8 +1043,16 @@ fn blit( if sw == 0 || sh == 0 { return; } - let sx_pct = if kf.scale_x == 0 { 100 } else { kf.scale_x }; - let sy_pct = if kf.scale_y == 0 { 100 } else { kf.scale_y }; + // `scale = 0` means COLLAPSED TO NOTHING, not "unset". This used to coerce + // 0 → 100 %, which drew a fully-collapsed element at full size. The disc + // settles it: of 15 493 elements with a keyframe group, **2 166 have at + // least one zero-scale keyframe and not one has zero on every keyframe** — + // and 1 762 of them grow back out of it (`ptlogo_eff3.t32` runs 0 % → 200 %). + // An "unset" marker that no element ever uses throughout is not a marker. + let (sx_pct, sy_pct) = (kf.scale_x, kf.scale_y); + if sx_pct == 0 || sy_pct == 0 { + return; + } let dw = (sw * sx_pct / 100).max(1); let dh = (sh * sy_pct / 100).max(1); // Keep the pivot point fixed as the element scales. diff --git a/docs/port/HANDOFF.md b/docs/port/HANDOFF.md index f3da9ec4..258560a7 100644 --- a/docs/port/HANDOFF.md +++ b/docs/port/HANDOFF.md @@ -291,6 +291,15 @@ authored version can be deleted. the defensible one there — see below. Still worth flagging plateau-less elements in an export rather than silently inheriting our guess; it is one pass over the keyframes. + ✅ **One concrete part of it is fixed (2026-08-28): `scale = 0` no longer + renders at full size.** `blit`/`fill_quad` coerced `scale == 0` to 100 %, so an + element collapsed to nothing was drawn full-size. The disc settles the reading: + 2 166 elements have a zero-scale keyframe, **not one is zero throughout**, and + 1 762 grow back out of zero — so 0 means collapsed, not "unset". **Renders are + 24/24 byte-identical** on `GP_TITLE` (all 16 builds), `GP_PAUSE_MENU` and + `GP_OPTIONS`, so nothing you rely on moves; the 126 elements the old + code actually painted are **all in `GP_READY_ROOM.pak`**, already a no-go. + [`structures/ui-rat-layout.md`](../re/structures/ui-rat-layout.md) 🔴 **"rest = last keyframe" is refuted** as the fix. The splash has three sibling glows with identical structure and times, differing in one byte (`a=212` vs `a=255` at `t=45`); that rule would make `anima_eff` alone diff --git a/docs/re/METHOD.md b/docs/re/METHOD.md index 71f3ce21..cdc115db 100644 --- a/docs/re/METHOD.md +++ b/docs/re/METHOD.md @@ -408,3 +408,14 @@ agent's loop prompt, i.e. nowhere durable. See [`README.md`](README.md) for the a heredoc two lines later wrote nowhere — while the `echo` after it still printed "written". A success message after a failed redirect is not a success. Use `(setsid nohup … &)` and keep the file-writing in its own call. +* **Read the whole function before describing what it does.** I wrote "a + zero-scale pose paints a 1-pixel sliver" into a tool's docstring, from the + `.max(1)` in the size arithmetic. Two lines above it, a guard coerced + `scale == 0` to **100 %**, so the `.max(1)` never saw a zero and the element + rendered at *full size* — a much larger bug than the one I described. I had + read the lines I went looking for and stopped. +* **Never diff a file a background job might still be writing.** A comparison + loop over an output directory reported `GP_TITLE` build 6 as changed; the pixel + diff was then all zeros, because `cmp` had read a half-written PNG. Compare + only after the job signals done, or skip the newest file. A "difference" that + vanishes on re-examination is usually a race, not a flake. diff --git a/docs/re/REFUTED.md b/docs/re/REFUTED.md index 77aac267..7939fcfa 100644 --- a/docs/re/REFUTED.md +++ b/docs/re/REFUTED.md @@ -408,3 +408,8 @@ neighbourhood, not just the line. makes `palogo_anima_eff` alone invisible while `gamearts_eff` and `seta_eff` stay lit. Capture box-mean ratios (0.717 / 0.723 / **0.772**) go the other way too. [`structures/ui-resting-pose.md`](structures/ui-resting-pose.md) +* "a keyframe `scale` of 0 means *unset*, so render at 100 %" → **refuted by a + disc-wide control.** 2 166 elements have a zero-scale keyframe and **not one is + zero on every keyframe**, while 1 762 grow back out of zero (`ptlogo_eff3.t32` + runs 0 % → 200 %). Zero means collapsed; the renderer now draws nothing. + [`structures/ui-rat-layout.md`](structures/ui-rat-layout.md) diff --git a/docs/re/data/zero-scale-census.txt b/docs/re/data/zero-scale-census.txt new file mode 100644 index 00000000..c0c17404 --- /dev/null +++ b/docs/re/data/zero-scale-census.txt @@ -0,0 +1,24 @@ +# tools/re-capture/zero_scale_census.py -- 2026-08-28 + +elements with a keyframe group: 15493 + at least one keyframe at scale 0: 2166 + EVERY keyframe at scale 0 (would vanish): 0 + zero is a transient the element grows out of: 1762 +rest poses with scale 0%%: 195 + ... of which alpha > 0, so blit paints a 1-pixel sliver: 126 + ... alpha == 0, harmless: 69 + +by archive: + 126 GP_READY_ROOM.pak + +examples (pak, entry, element idx, name, sx, sy, alpha): + ('GP_READY_ROOM.pak', 0, 2, 'pbacropolis.t32', 0, 100, 255) + ('GP_READY_ROOM.pak', 0, 6, 'pbFriendly.t32', 0, 100, 255) + ('GP_READY_ROOM.pak', 0, 10, 'pbFriendly.t32', 0, 100, 255) + ('GP_READY_ROOM.pak', 0, 14, 'pbFriendly.t32', 0, 100, 255) + ('GP_READY_ROOM.pak', 7, 2, 'pbacropolis.t32', 0, 100, 255) + ('GP_READY_ROOM.pak', 7, 6, 'pbFriendly.t32', 0, 100, 255) + ('GP_READY_ROOM.pak', 7, 10, 'pbFriendly.t32', 0, 100, 255) + ('GP_READY_ROOM.pak', 7, 14, 'pbFriendly.t32', 0, 100, 255) + ('GP_READY_ROOM.pak', 74, 2, 'pbr_enemy.t32', 0, 100, 255) + ('GP_READY_ROOM.pak', 76, 2, 'pbr_enemy.t32', 0, 100, 255) diff --git a/docs/re/structures/ui-rat-layout.md b/docs/re/structures/ui-rat-layout.md index 24b69e45..ed256cc6 100644 --- a/docs/re/structures/ui-rat-layout.md +++ b/docs/re/structures/ui-rat-layout.md @@ -711,3 +711,48 @@ full cycle length has to come from measurement. ❔ The word at `+0x004` is **not** the keyframe count: `0x003c0000` (60) with 8 keyframes here, `0x001e0000` (30) with 3 in `ptloop01.rat`/`ptloop02.rat`. + +## ✅ `scale = 0` means COLLAPSED, not "unset" (2026-08-28) + +`ui_layout::blit` and `fill_quad` both used to open with + +```rust +let sx_pct = if kf.scale_x == 0 { 100 } else { kf.scale_x }; +``` + +so an element whose pose is *collapsed to nothing* was drawn at **full size**. +(The `.max(1)` two lines below never saw a zero — see [METHOD.md](../METHOD.md).) + +### The control, run before the change + +If `0` meant "unset", some element would use it throughout. Over the disc +([`data/zero-scale-census.txt`](../data/zero-scale-census.txt)): + +| | | +|---|---| +| elements with a keyframe group | 15 493 | +| at least one keyframe at scale 0 | 2 166 | +| **every** keyframe at scale 0 | **0** | +| zero is a transient the element grows back out of | **1 762** | + +Not one element on the disc is zero-scale throughout, and 1 762 grow out of zero +— `ptlogo_eff3.t32` runs `0 % → 200 %`. **An "unset" marker that nothing ever +uses throughout is not a marker.** Zero means zero. + +### The fix, and its reach + +Both functions now return without drawing when either scale is 0. + +* **Renders: 24 of 24 byte-identical** across `GP_TITLE` (all 16 builds), + `GP_PAUSE_MENU` and `GP_OPTIONS`. The change is additive + on every screen the port needs. +* 195 elements have a guessed rest pose at scale 0; **126 of those had a non-zero + alpha**, so the old code painted them at full size. +* ⚠️ **All 126 are in `GP_READY_ROOM.pak`** — the archive + [S1](../ready-room-probe.md) already declared a no-go. They are tactical-map + sprites (`pbacropolis`, `pbFriendly`, `pbr_enemy`) at `sx=0, sy=100`, i.e. + mid-horizontal-wipe, which the old coercion drew full-width. + +🟡 So this is a **real correctness fix with no visible effect on the menu port**. +It matters for anything that later reads `GP_READY_ROOM`, and it removes a case +where the renderer contradicted the format. diff --git a/tools/re-capture/zero_scale_census.py b/tools/re-capture/zero_scale_census.py new file mode 100644 index 00000000..054e56d5 --- /dev/null +++ b/tools/re-capture/zero_scale_census.py @@ -0,0 +1,78 @@ +"""Of the elements whose guessed rest pose has scale 0%, how many actually paint? + +⚠️ The premise this was first written with was WRONG. blit() did not draw a +zero-scale element as a 1-pixel sliver: it coerced `scale == 0` to **100 %**, so +a fully-collapsed element rendered at FULL SIZE. The `.max(1)` further down never +saw a zero. Read the code before describing what it does -- the guard was two +lines above the arithmetic I had read. + +That coercion is now removed (scale 0 draws nothing); this script is what sized +the change. It counts elements whose guessed rest pose is zero-scale and whose +alpha is non-zero, i.e. the ones the old code actually painted. +""" +import struct, zlib, glob, os, collections +DECL_AT, DECL_ENTRY, KEYFRAME = 0x20, 60, 40 +def entries(base): + stub=open(base+".pak","rb").read() + if stub[:4]!=b"IPFB": return + n=struct.unpack_from(">I",stub,4)[0] + segs=sorted(glob.glob(base+".p[0-9][0-9]")) + if not segs: return + blob=b"".join(open(s,"rb").read() for s in segs) + for i in range(n): + h,off,sz=struct.unpack_from(">III",stub,0x10+12*i) + st=blob[off:off+sz] + if len(st)<10: continue + try: yield i,h,(zlib.decompress(st[10:]) if st[:2]==b"Z1" else st) + except Exception: continue +def elements_t(d): + if d[:4]!=b"RATC": return [] + count=struct.unpack_from(">I",d,0x14)[0] + if not (0len(d): return [] + names.append(d[o:o+28].split(b"\0")[0].decode("ascii","replace")) + out,pos=[],DECL_AT+count*DECL_ENTRY + for _ in range(count): + if pos+8>len(d): break + idx,frames=struct.unpack_from(">II",d,pos) + if idx>=count or frames==0 or frames>4096: break + first=pos+12; end=first+frames*KEYFRAME-4 + poses,times=[],[] + for k in range(frames): + blk=first+k*KEYFRAME + if blk+36>len(d) or blk+36>end: break + poses.append(struct.unpack_from(">I",d,blk)+struct.unpack_from(">II",d,blk+16)+struct.unpack_from(">ii",d,blk+28)) + times.append(struct.unpack_from(">i",d,blk+36)[0] if blk+40<=end else None) + if poses: out.append((idx,names[idx],times,poses)) + pos=end + return out +has_plateau=lambda p: any(p[i]==p[i+1] for i in range(len(p)-1)) +def dwell_pick(t,p): + best=(0,-1) + for k in range(len(t)-1): + if t[k] is None or t[k+1] is None: continue + d=t[k+1]-t[k] + if d>=best[1]: best=(k,d) + return best[0] +alpha=lambda p:(p[0]>>24)&0xff +n=paint=0; where=collections.Counter(); ex=[] +for pak in sorted(glob.glob("/work/sylph_extract/dat/GP_*.pak")): + for i,h,d in entries(pak[:-4]): + for idx,name,t,p in elements_t(d): + if has_plateau(p): continue + r=p[dwell_pick(t,p)] + if r[1]==0 or r[2]==0: + n+=1 + if alpha(r)>0: + paint+=1; where[os.path.basename(pak)]+=1 + if len(ex)<10: ex.append((os.path.basename(pak),i,idx,name,r[1],r[2],alpha(r))) +print(f"rest poses with scale 0%%: {n}") +print(f" ... of which alpha > 0, so blit paints a 1-pixel sliver: {paint}") +print(f" ... alpha == 0, harmless: {n-paint}") +if paint: + print("\nby archive:"); [print(f" {c:4d} {k}") for k,c in where.most_common()] + print("\nexamples (pak, entry, element idx, name, sx, sy, alpha):") + [print(" ",e) for e in ex]