"""The worst gap between two decision points, out of the scene graph (G1). FINDINGS 51.3 measured that a ring's lookahead is ACCUMULATED out of `pipe - wire` and that a seek spends all of it, so what a branch point costs is set by the rate and by the time since the last branch. 55.5 rehearsed a seek on the machine and could not ask the question that matters, because nothing in this tree knew where the branch points ARE: what is the WORST gap, in seconds of play, between two consecutive decision points, and does the refill climb survive it? Only the arcade scene graph knows. This answers it in the currency 51.3 established. python3 tools/import/scenegraph.py # writes tmp/scenegraph.json python3 tools/analysis/25_scene_graph.py --kbps R [R ...] [--ring KB [KB ...]] `--kbps` is REQUIRED and takes no default, for the reason FINDINGS 50 gives. THIS FILE KNOWS NOTHING ABOUT WHERE THE TABLE CAME FROM, deliberately. It reads `DLXSCENE1`, which is this project's own schema; `tools/import/scenegraph.py` is the single file in the tree that knows anything about the outside projects the table is built from, and it carries their attribution. Nothing is vendored. """ import sys, os, json, argparse, importlib.util sys.path.insert(0, "tools/encoder") TABLE = os.environ.get("DLX_SCENEGRAPH", "tmp/scenegraph.json") LD_FPS = 23.976 # the medium's frame rate; one frame is the comparison floor # ------------------------------------------------------------------- graph class Node: """One (scene, sequence): a clip, its exits, and whether entering it seeks.""" def __init__(self, scene, name, seq): self.scene, self.name, self.seq = scene, name, seq self.start = seq["start_ms"] # ms, or -1 for no seek self.seeks = self.start >= 0 self.timeout_ms = seq["timeout_ms"] self.exits = [(seq["timeout_ms"], "timeout", seq["timeout_next"])] for a in seq["actions"]: # The player may press as early as `from_ms`, so that is the least # play this clip can deliver before the branch it leads to. self.exits.append((a["from_ms"], "action", a["next"])) @property def key(self): return f"{self.scene}.{self.name}" def build_graph(scenes): nodes = {} for scene, seqs in scenes.items(): for name, seq in seqs.items(): n = Node(scene, name, seq) nodes[n.key] = n return nodes def worst_gap(nodes): """Least play time, in ms, between one seek and the next. A seek is entering a sequence whose start is >= 0; a negative start means the disc keeps playing, so play ACCUMULATES across such sequences and the gap is a shortest path over them. Bellman-Ford rather than Dijkstra because a zero-length timeout is common (`start_alive` chains) and the graph has cycles; all weights are non-negative so it terminates. A null exit ends the scene: the player moves to another scene entirely, which is a seek AND a container change (FINDINGS 53), so it counts as a seek and is flagged. """ INF = float("inf") dist = {k: (0.0 if n.seeks else INF) for k, n in nodes.items()} for _ in range(len(nodes) + 1): changed = False for n in nodes.values(): if dist[n.key] == INF: continue for elapsed, kind, tgt in n.exits: if tgt is None: continue tk = f"{n.scene}.{tgt}" if tk not in nodes: continue d = dist[n.key] + max(0.0, float(elapsed)) if not nodes[tk].seeks and d < dist[tk] - 1e-9: dist[tk], changed = d, True if not changed: break best = {} for n in nodes.values(): if dist[n.key] == INF: continue for elapsed, kind, tgt in n.exits: tk = f"{n.scene}.{tgt}" if tgt is not None else None ends_scene = tgt is None if ends_scene or (tk in nodes and nodes[tk].seeks): g = dist[n.key] + max(0.0, float(elapsed)) # One entry per (source, destination): several input windows can # lead to the same clip and only the earliest of them binds. k = (n.key, tgt) if k not in best or g < best[k][0]: best[k] = (g, n.key, tgt if tgt else "", kind, ends_scene, n.scene) return sorted(best.values()), dist # ------------------------------------------------------ 51.3's currency def slack_model(gap_s, kbps, wire_kbps, mean_rec_b): """Records of lookahead accrued in `gap_s` seconds of play at `kbps`, and the seconds one record of lookahead costs. This is 51.3's surplus model and nothing more: slack accrues at (pipe - wire) bytes per second. The paced rig is the measurement; this says whether the gap is even in the right order of magnitude. """ surplus = (kbps - wire_kbps) * 1024.0 if surplus <= 0: return 0.0, float("inf") return surplus * gap_s / mean_rec_b, mean_rec_b / surplus def main(): ap = argparse.ArgumentParser() ap.add_argument("--kbps", type=float, nargs="+", required=True, help="delivered pipe rates, KB/s. REQUIRED, no default " "(FINDINGS 50).") ap.add_argument("--table", default=TABLE, help="DLXSCENE1 scene table") ap.add_argument("--container", default="tmp/rc_fr_singe_scsi_span.dlx", help="container the wire demand and mean record come from") ap.add_argument("--ring", type=float, nargs="+", default=[256, 512]) ap.add_argument("--top", type=int, default=12) a = ap.parse_args() if not os.path.exists(a.table): print(f"no scene table at {a.table} -- run:\n" f" python3 tools/import/scenegraph.py") return 2 doc = json.load(open(a.table)) if doc.get("format") != "DLXSCENE1": print(f"{a.table}: not a DLXSCENE1 table") return 2 nodes = build_graph(doc["scenes"]) c = doc["counts"] nseek = sum(1 for n in nodes.values() if n.seeks) print(f"=== the scene graph ({a.table})") for s in doc["sources"]: print(f" from {s['name']} ({s['licence']}, {s['holder']}): {s['role']}") print(f" scenes {c['scenes']}, sequences {c['sequences']}, " f"input windows {c['windows']}") print(f" sequences entered by a SEEK: {nseek}/{c['sequences']} " f"({100*nseek/c['sequences']:.1f}%); the rest play on from where the " f"disc is") print(f" scene order: {len(doc['rows'])} rows x {len(doc['rows'][0])}") # Gates. A parser that quietly dropped a branch would produce a SMALLER # graph and a LONGER worst gap -- it would fail in the flattering direction. assert c["sequences"] == 516, f"expected 516 sequences, got {c['sequences']}" assert c["windows"] == 906, f"expected 906 input windows, got {c['windows']}" # ---- the measurement gaps, dist = worst_gap(nodes) play = [g for g in gaps if g[5] != "attract_mode"] zero = [g for g in play if g[0] <= 1e-9] print() print("=== the worst gap between two consecutive decision points") print(" A 'gap' is the LEAST play time the disc delivers between one seek") print(" and the next: the earliest an input window opens, chained across") print(" sequences the disc plays through without seeking. One entry per") print(" (source, destination); attract mode is excluded and reported") print(" separately, because nothing branches there under a 12 fps budget.") print(f" {'gap s':>7} from -> to") for g, src, tgt, kind, ends, scene in play[:a.top]: print(f" {g/1000:>7.3f} {src} -{kind}-> {tgt}" + (" [SCENE CHANGE]" if ends else "")) sc_gaps = [g for g in play if g[4]] print(f" ... {len(play)} distinct transitions into a seek " f"({len(gaps)-len(play)} more in attract mode)") worst = play[0][0] / 1000.0 med = play[len(play) // 2][0] / 1000.0 print(f" WORST {worst:.3f} s, median {med:.3f} s, " f"best {play[-1][0]/1000:.3f} s") print(f" SCENE CHANGES specifically ({len(sc_gaps)} of them, and each also") print(f" needs a header before its frame 0, FINDINGS 53/55.1): worst " f"{sc_gaps[0][0]/1000:.3f} s, median " f"{sc_gaps[len(sc_gaps)//2][0]/1000:.3f} s") print(f" ZERO-PLAY BRANCHES: {len(zero)} of {len(play)} " f"({100*len(zero)/len(play):.1f}%) open an input window at t=0 of a") print(" clip the disc SEEKED to, so two seeks can fall back to back with no") print(" play between them at all. A rule of the form 'has there been") print(" enough play since the last branch' (51.2's ring_may_seek) can be") print(" answered NO by the content, not by the buffer.") # ---- what the input layer has to survive, from the same table inputs, windows = {}, [] for n in nodes.values(): for x in n.seq["actions"]: inputs[x["input"]] = inputs.get(x["input"], 0) + 1 windows.append(x["to_ms"] - x["from_ms"]) windows.sort() print() print("=== what the input layer has to survive") print(" " + ", ".join(f"{k} {v}" for k, v in sorted(inputs.items(), key=lambda x: -x[1]))) diag = sum(v for k, v in inputs.items() if k in ("upleft", "upright", "downleft", "downright")) print(f" diagonals are {diag} windows of {len(windows)}: rare enough for a " f"port to drop\n and not droppable by one aiming at the arcade") print(f" window length: shortest {windows[0]:.0f} ms " f"({windows[0]/(1000/12):.2f} frame slots at 12 fps), p10 " f"{windows[len(windows)//10]:.0f} ms, median " f"{windows[len(windows)//2]:.0f} ms") print(" the floor is one to two frames wide (54.4: a slot is 72.13 or") print(" 90.16 ms, never 83.33), so input cannot be polled on the frame tick") # ---- 51.3's currency if os.path.exists(a.container): spec = importlib.util.spec_from_file_location( "seek_slack", "tools/analysis/20_seek_slack.py") m = importlib.util.module_from_spec(spec) spec.loader.exec_module(m) import ratectl as RC d, rec = m.records(a.container) mean_rec = float(rec.mean()) wire = mean_rec * 12 / 1024 + RC.AUDIO_KBPS gs = [g[0] / 1000.0 for g in play] print() print(f"=== what that gap buys, at explicit rates " f"({os.path.basename(a.container)}: mean record " f"{mean_rec/1024:.1f} KB, wire {wire:.1f} KB/s)") print(f" {'ring KB':>8} {'pipe':>7} {'ceiling':>8} {'climb s':>8} " f"{'median gap':>11} {'accrued':>8} {'under climb':>12}") for ring_kb in a.ring: ring = int(ring_kb * 1024) for kbps in a.kbps: fill = (kbps - RC.AUDIO_KBPS) * 1024 / 12 lo, hi, ring_ref, rate_ref = m.paced_sim(rec, ring, fill) ceiling = int(hi.max()) accrued, per_rec = slack_model(med, kbps, wire, mean_rec) climb = ceiling * per_rec under = sum(1 for g in gs if g < climb) print(f" {ring_kb:>8.0f} {kbps:>7.1f} {ceiling:>8} " f"{climb:>8.2f} {med:>11.3f} {accrued:>8.2f} " f"{f'{under}/{len(gs)}':>12} {100*under/len(gs):.0f}%") # What a branch costs when the gap before it bought nothing. The ring # is empty after a seek and the decoder is released at the prefill depth # (55.4's shipped policy is 2 records), so this is the stall the player # eats every time -- not the climb to the ceiling, which is what it # needs in order to TOLERATE the next one. A scene change additionally # needs its header before frame 0; 22_scene_load.py prices that case # properly, clocks included. PREFILL_REC, HDR_B = 2, 6164 print() print(f" A branch taken on an empty ring, at the shipped prefill of " f"{PREFILL_REC} records:") for kbps in a.kbps: b = PREFILL_REC * mean_rec ms = b / (kbps * 1024) * 1000 hms = (b + HDR_B) / (kbps * 1024) * 1000 print(f" {kbps:>7.1f} KB/s: {ms:>7.1f} ms " f"({ms/(1000/12):.2f} frame slots), and {hms:>7.1f} ms " f"({hms/(1000/12):.2f}) if it is a scene change carrying " f"{HDR_B:,} header bytes") print() print(" 'climb s' is 51.3's: seconds of play to refill from empty to") print(" the ceiling. 'under climb' is how many of this game's own") print(" branch points arrive sooner than that, i.e. are reached with") print(" LESS lookahead than the one before them. The worst gap is") print(f" {worst:.3f} s and buys nothing at any rate in this table.") else: print(f"\n{a.container}: MISSING -- rate half skipped") # ---- the cross-check, and what it is worth print() print("=== the second table, and why it is not a second transcription") cross, meta = doc.get("crosscheck"), doc.get("crosscheck_meta") if not cross: print(" none in this table -- the import ran without it") return 0 print(f" {meta['chapters']} chapters, {meta['scenes_mapped']} scenes") print(f" PROVENANCE: {meta['provenance']}") print(" FINDINGS 16 planned to diff two INDEPENDENT transcriptions to") print(" catch transcription errors. There is only one transcription.") print(" This diff catches CONVERSION errors and nothing more.") FRAME_MS = 1000.0 / LD_FPS offs, durs, same, diff, missing = [], [], 0, 0, 0 renames, inputs_differ, examples = 0, [], [] for scene, seqs in sorted(cross.items()): for seq, ch in sorted(seqs.items()): n = nodes.get(f"{scene}.{seq}") if n is None: missing += 1 continue if n.seeks: offs.append(ch["start_ms"] - n.start) durs.append(((ch["end_ms"] - ch["start_ms"]) - n.timeout_ms, ch["chapter"])) ce = sorted((x["input"], x["next"]) for x in ch["actions"]) de = sorted((x["input"], x["next"]) for x in n.seq["actions"]) if ce == de: same += 1 continue diff += 1 ci = sorted(i for i, _ in ce) di = sorted(i for i, _ in de) if ci != di: inputs_differ.append((ch["chapter"], ci, di)) else: renames += 1 if len(examples) < 3: examples.append((ch["chapter"], ce, de)) if offs: offs.sort() print(f" START TIMES are on different timelines and do not compare: " f"{len(offs)} seeking chapters,") print(f" offset spread {min(offs)/1000:,.1f} s .. " f"{max(offs)/1000:,.1f} s, median {offs[len(offs)//2]/1000:,.1f} s" f" -- not a constant, and not even one sign.") if durs: dd = sorted(x for x, _ in durs) agree = sum(1 for x in dd if abs(x) <= FRAME_MS) print(f" DURATIONS compare (offset-invariant): {agree}/{len(dd)} " f"within one frame of the medium ({100*agree/len(dd):.1f}%), " f"median {dd[len(dd)//2]:,.0f} ms") for x, ch in sorted(durs, key=lambda x: -abs(x[0]))[:3]: print(f" widest {ch}: {x/1000:+.3f} s") if same + diff: print(f" BRANCH STRUCTURE compares: {same}/{same+diff} chapters have " f"the identical set of (input -> target) edges " f"({100*same/(same+diff):.1f}%)") if diff: print(f" {renames} of the {diff} differ only in what a target " f"sequence is NAMED") DIAG = ("upleft", "upright", "downleft", "downright") lost = [ch for ch, ci, di in inputs_differ if set(di) - set(ci) and all(x in DIAG for x in set(di) - set(ci))] print(f" {len(inputs_differ)} differ in the INPUT SET, and " f"{len(lost)} of those are the other table dropping the arcade's") print(" DIAGONALS: a controller decision, not a transcription " "difference.") for ch, ci, di in inputs_differ: if ch not in lost: print(f" the remaining one: {ch}, {ci} vs {di}") for ch, ce, de in examples: print(f" e.g. {ch}\n cross {ce}\n graph {de}") if missing: print(f" {missing} chapters have no sequence in the graph " f"(the other project added them)") return 0 if __name__ == "__main__": sys.exit(main())