From 515456c59a914e6c6067607f130503808cfc7a22 Mon Sep 17 00:00:00 2001 From: Sylpheed RE agent Date: Sat, 29 Aug 2026 04:24:40 +0000 Subject: [PATCH] re(ui): quantify what changing the rest rule would do disc-wide The open question was whether "last keyframe" holds beyond the two elements I could score against a capture. It cannot be scored disc-wide -- only two ambiguous elements sit on a screen with a live capture -- but the blast radius can be measured, and it argues the same way. genuinely ambiguous elements 2 305 the two rules AGREE on 409 (17.7 %) they DIFFER on 1 896 (82.3 %) dwell (current): invisible pose 1 711 (74.2 %), zero-scale 195 (8.5 %) last : invisible pose 1 618 (70.2 %), zero-scale 43 (1.9 %) Two things follow. It is not a marginal choice: the rules disagree on 82% of the affected elements, so "either is fine" is not available. And the current rule produces 4.5x more degenerate poses -- a zero-scale pose is collapsed to nothing, i.e. an element's PRE-ROLL before it has grown in, which is definitionally not a rest. 195 elements currently rest at a frame they are only passing through, against 43 under last. That is an argument from the data's own structure rather than from the two captures, and it points the same direction. Kept honest: it is indirect. Fewer degenerate results is not the same as more correct results, and last still returns an invisible pose 70% of the time -- right for a transient element, wrong for a persistent one. The default stays put; the numbers are in HANDOFF for whoever decides. --- docs/port/HANDOFF.md | 7 ++- docs/re/data/rest-rule-blast-radius.txt | 8 +++ docs/re/structures/ui-resting-pose.md | 30 ++++++++++ tools/re-capture/rest_rule_blast_radius.py | 68 ++++++++++++++++++++++ 4 files changed, 112 insertions(+), 1 deletion(-) create mode 100644 docs/re/data/rest-rule-blast-radius.txt create mode 100755 tools/re-capture/rest_rule_blast_radius.py diff --git a/docs/port/HANDOFF.md b/docs/port/HANDOFF.md index 50a93a58..8ebf4961 100644 --- a/docs/port/HANDOFF.md +++ b/docs/port/HANDOFF.md @@ -415,7 +415,12 @@ authored version can be deleted. animation, so it fixes which pose matches *those* captures, not which is canonically at rest. Default unchanged — it is better on both screens where it fires and identical on the other three, but it would move 2 305 elements - disc-wide on two measurements. The single disagreement + disc-wide on two measurements. + 📊 The disc-wide blast radius, for whoever decides: the rules **differ on 82.3 %** + of those 2 305, so "either is fine" is not available — and the current rule + returns a **zero-scale** (collapsed, pre-roll) pose for **195** of them against + **43** under `last`, a 4.5× reduction in provably-degenerate results. That + argues the same way as the captures, from the data's own structure. The single disagreement is `palogo_anima_eff.t32` on the developer splash, and the current answer is 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 diff --git a/docs/re/data/rest-rule-blast-radius.txt b/docs/re/data/rest-rule-blast-radius.txt new file mode 100644 index 00000000..9b0d7a33 --- /dev/null +++ b/docs/re/data/rest-rule-blast-radius.txt @@ -0,0 +1,8 @@ +# tools/re-capture/rest_rule_blast_radius.py over dat/GP_*.pak -- 2026-08-29 + +genuinely ambiguous elements (plateau-less, 2+ keyframes): 2305 + the two rules AGREE on : 409 (17.7 %) + they DIFFER on : 1896 (82.3 %) + + rule dwell : returns an INVISIBLE pose 1711 ( 74.2 %), zero-scale 195 ( 8.5 %) + rule last : returns an INVISIBLE pose 1618 ( 70.2 %), zero-scale 43 ( 1.9 %) diff --git a/docs/re/structures/ui-resting-pose.md b/docs/re/structures/ui-resting-pose.md index f469909d..da01e135 100644 --- a/docs/re/structures/ui-resting-pose.md +++ b/docs/re/structures/ui-resting-pose.md @@ -491,3 +491,33 @@ provably identical on the other three, but it would alter the rest pose of ✅ Unaffected by the earlier correction: all **195** zero-scale rest poses are inside the genuinely-ambiguous 2 305 — none is a single-keyframe element. + +### The disc-wide blast radius — measurable even though the rule is not scorable there + +Only two ambiguous elements sit on a screen with a live capture, so the rule +cannot be *scored* disc-wide. What can be quantified is what changing it would +do ([`tools/re-capture/rest_rule_blast_radius.py`](../../../tools/re-capture/rest_rule_blast_radius.py), +[data](../data/rest-rule-blast-radius.txt)): + +| | dwell (current) | `last` | +|---|---|---| +| the two rules **differ** | **1 896 of 2 305 (82.3 %)** | | +| returns an **invisible** pose | 1 711 (74.2 %) | 1 618 (70.2 %) | +| returns a **zero-scale** pose | **195 (8.5 %)** | **43 (1.9 %)** | + +Two things worth reading off this. + +**It is not a marginal choice.** The rules disagree on 82 % of the affected +elements, so "either is fine" is not available. + +**The current rule produces 4.5× more degenerate poses.** A zero-scale pose is +*collapsed to nothing* ([rat layout](ui-rat-layout.md)) — an element's **pre-roll**, +before it has grown in. A pre-roll is definitionally not a rest, so 195 elements +currently rest at a frame the element is only passing through, against 43 under +`last`. That is an argument from the data's own structure rather than from the +two captures, and it points the same way they do. + +🟡 It remains **indirect**: fewer degenerate results is not the same as more +correct results, and `last` still returns an invisible pose 70 % of the time — +which for a transient element may well be right, and for a persistent one would +be wrong. The default stays put. diff --git a/tools/re-capture/rest_rule_blast_radius.py b/tools/re-capture/rest_rule_blast_radius.py new file mode 100755 index 00000000..1d6b22ea --- /dev/null +++ b/tools/re-capture/rest_rule_blast_radius.py @@ -0,0 +1,68 @@ +"""What would each rest-pose rule do to the 2 305 genuinely-ambiguous elements? + +Only two of them sit on a screen with a live capture, so the rule cannot be +scored disc-wide. What CAN be quantified is the blast radius: how often the +rules disagree, and how often each returns a pose that draws nothing. +""" +import struct, zlib, glob, 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 (zlib.decompress(st[10:]) if st[:2]==b"Z1" else st) + except Exception: continue +def elements(d): + if d[:4]!=b"RATC": return [] + count=struct.unpack_from(">I",d,0x14)[0] + if not (0len(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): + b=first+k*KEYFRAME + if b+36>len(d) or b+36>end: break + poses.append(struct.unpack_from(">I",d,b)+struct.unpack_from(">II",d,b+16)+struct.unpack_from(">ii",d,b+28)) + times.append(struct.unpack_from(">i",d,b+36)[0] if b+40<=end else None) + if poses: out.append((times,poses)) + pos=end + return out +def dwell(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 q:(q[0]>>24)&0xff +n=agree=0 +inv=collections.Counter(); deg=collections.Counter() +for pak in sorted(glob.glob("/work/sylph_extract/dat/GP_*.pak")): + for d in entries(pak[:-4]): + for t,p in elements(d): + if len(p)==1: continue + if any(p[i]==p[i+1] for i in range(len(p)-1)): continue + n+=1 + rd=p[dwell(t,p)]; rl=p[-1] + agree += rd==rl + for tag,r in (("dwell",rd),("last",rl)): + if alpha(r)==0: inv[tag]+=1 + if r[1]==0 or r[2]==0: deg[tag]+=1 +print(f"genuinely ambiguous elements (plateau-less, 2+ keyframes): {n}") +print(f" the two rules AGREE on : {agree} ({100*agree/n:.1f} %)") +print(f" they DIFFER on : {n-agree} ({100*(n-agree)/n:.1f} %)") +print() +for tag in ("dwell","last"): + print(f" rule {tag:6}: returns an INVISIBLE pose {inv[tag]:5d} ({100*inv[tag]/n:5.1f} %)," + f" zero-scale {deg[tag]:4d} ({100*deg[tag]/n:4.1f} %)")