diff --git a/docs/port/HANDOFF.md b/docs/port/HANDOFF.md index 50a93a5..8ebf496 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 0000000..9b0d7a3 --- /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 f469909..da01e13 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 0000000..1d6b22e --- /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} %)")