Files
gramps 92d875c343 tools(stats): mine sessions from the logs, split by sink
`scripts/session-stats.py` turns every `startup*.log` into one row per FramePump session: tier,
scene, measured fps, drops, stalls, average/worst render, capture rate, and the build — with the
sink marked, because that is what makes the numbers quotable:

- **RECORD** — machine only, no socket. The cleanest minimum-config evidence.
- **TEST** — machine + socket, no audience; recognised by `Test session dock open` /
  `Test chat simulation`, which the creator pointed out are recorded too.
- **LIVE** — network and audience included.
- **UNKNOWN** — no marker; not evidence.

Verdicts use the average render against the frame budget, not the worst: the worst is dominated by
the 100 ms first-frame bake (a session that "holds" can still show worst 35 ms). `holds` = no drops,
no stalls, average under half the budget.

First run over the existing logs already separates the two halves of TASK 33: static Starting scenes
render at ~1.5 ms, the same pump with display capture running at 35–46 ms — the capture path, not the
overlays, is where a budget streamer loses the frame budget.
2026-11-09 09:04:04 -08:00

257 lines
9.6 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""Mine LlamaCasty's own logs for per-session performance evidence.
Why this exists (2026-11-09): a recording and a livestream share the entire pipeline — capture,
compositing, one ffmpeg at the selected tier. Only the sink differs. That makes recordings the
cleanest evidence for the machine-side minimum configuration, because the network cannot pollute
the numbers, and livestreams the evidence for everything else.
Usage
python3 scripts/session-stats.py # every log under %APPDATA%\\ytLlive
python3 scripts/session-stats.py path/to/startup.log ...
python3 scripts/session-stats.py --json
What a "session" is: from `FramePump started` (which carries the tier and scene) to the next start
or end of file, with the sink inferred from the markers inside it:
Test session dock / Test chat simulation -> TEST (private broadcast, real socket)
Broadcast created / Broadcast bound -> LIVE
Recording ended / Recording discarded -> RECORD (both -> LIVE+RECORD)
no marker -> UNKNOWN (crashed launch, or a paused session)
TEST is the one that looks like LIVE and behaves like RECORD: the full pipeline and the socket, but
no audience and simulated chat. It is good machine evidence and useless congestion evidence.
`holds` is the published-minimum rule, deliberately strict: zero pump drops, zero stalls, and worst
render under HALF the frame budget — headroom, not survivability.
"""
from __future__ import annotations
import argparse
import glob
import json
import os
import re
import sys
from dataclasses import dataclass, field, asdict
from statistics import mean
TIER_RE = re.compile(
r"FramePump started \((\d+)×(\d+) @ (\d+) fps\).*scene='([^']*)'")
STATS_RE = re.compile(
r"FramePump stats: (\d+)/(\d+) frames per (\d+)s from (\d+) renders, "
r"avg render ([\d.]+)ms.*?avg submit ([\d.]+)ms, avg wait ([\d.]+)ms, "
r"worst render ([\d.]+)ms, worst submit ([\d.]+)ms, dropped (\d+), stalls (\d+)")
CAPTURE_RE = re.compile(
r"ScreenCapture telemetry \[([^\]]+)\]: \d+ frames in [\d.]+s \(([\d.]+)/s\), "
r"conv avg (\d+)ms max (\d+)ms")
STAMP_RE = re.compile(r"^(\d{4}-\d\d-\d\d \d\d:\d\d:\d\d\.\d{3})")
BUILD_RE = re.compile(r"Build ([0-9a-f]{6,})")
TEST_MARKERS = ("Test session dock open", "Test chat simulation")
LIVE_MARKERS = ("Broadcast created:", "Broadcast bound:")
RECORD_MARKERS = ("Recording ended", "Recording discarded")
@dataclass
class Session:
log: str
started: str = ""
build: str = ""
scene: str = ""
width: int = 0
height: int = 0
fps: int = 0
test: bool = False
live: bool = False
record: bool = False
samples: int = 0
frames: int = 0
dropped: int = 0
stalls: int = 0
avg_render: float = 0.0
worst_render: float = 0.0
avg_submit: float = 0.0
avg_wait: float = 0.0
capture_rates: list[float] = field(default_factory=list)
capture_conv: list[int] = field(default_factory=list)
stall_events: int = 0
@property
def sink(self) -> str:
if self.test:
return "TEST"
if self.live and self.record:
return "LIVE+REC"
if self.live:
return "LIVE"
if self.record:
return "RECORD"
return "UNKNOWN"
@property
def tier(self) -> str:
return f"{self.width}x{self.height}@{self.fps}"
@property
def seconds(self) -> float:
return self.samples * 5.0
@property
def measured_fps(self) -> float:
return self.frames / self.seconds if self.seconds else 0.0
@property
def verdict(self) -> str:
# Deliberately judged on the AVERAGE: `worst render` includes one-off startup hitches
# (100 ms while the first frame bakes) that say nothing about sustained capability.
budget = 1000.0 / self.fps if self.fps else 0.0
if not self.samples:
return "no-data"
if self.dropped or self.stalls or self.avg_render > budget:
return "over-budget"
if self.avg_render > budget / 2:
return "strained"
return "holds"
def default_root() -> str:
r"""%APPDATA%\ytLlive, including when the script is run from WSL (no APPDATA set)."""
if os.environ.get("APPDATA"):
return os.path.join(os.environ["APPDATA"], "ytLlive")
# WSL / Linux: the Windows profile is mounted under /mnt/c/Users/<name>.
candidates = sorted(glob.glob("/mnt/c/Users/*/AppData/Roaming/ytLlive"))
if candidates:
return max(candidates, key=os.path.getmtime)
return os.path.expanduser("~/AppData/Roaming/ytLlive")
def default_logs() -> list[str]:
root = default_root()
paths = sorted(glob.glob(os.path.join(root, "startup*.log")))
paths += sorted(glob.glob(os.path.join(root, "instances", "*", "startup*.log")))
return paths
def parse(paths: list[str]) -> list[Session]:
sessions: list[Session] = []
for path in paths:
try:
lines = open(path, encoding="utf-8", errors="ignore").read().splitlines()
except OSError as exc:
print(f"skip {path}: {exc}", file=sys.stderr)
continue
current: Session | None = None
build = ""
for line in lines:
build_match = BUILD_RE.search(line)
if build_match:
build = build_match.group(1)
stamp = STAMP_RE.match(line)
tier = TIER_RE.search(line)
if tier:
current = Session(
log=os.path.basename(path),
started=stamp.group(1) if stamp else "",
build=build,
width=int(tier.group(1)),
height=int(tier.group(2)),
fps=int(tier.group(3)),
scene=tier.group(4),
)
sessions.append(current)
continue
if current is None:
continue
if "FramePump stall:" in line:
current.stall_events += 1
continue
stats = STATS_RE.search(line)
if stats:
current.samples += 1
current.frames += int(stats.group(1))
current.avg_render += float(stats.group(5))
current.avg_submit += float(stats.group(6))
current.avg_wait += float(stats.group(7))
current.worst_render = max(current.worst_render, float(stats.group(8)))
current.dropped += int(stats.group(10))
current.stalls += int(stats.group(11))
continue
capture = CAPTURE_RE.search(line)
if capture:
current.capture_rates.append(float(capture.group(2)))
current.capture_conv.append(int(capture.group(3)))
continue
if any(m in line for m in TEST_MARKERS):
current.test = True
if any(m in line for m in LIVE_MARKERS):
current.live = True
if any(m in line for m in RECORD_MARKERS):
current.record = True
for s in sessions:
if s.samples:
s.avg_render /= s.samples
s.avg_submit /= s.samples
s.avg_wait /= s.samples
return [s for s in sessions if s.samples]
def table(sessions: list[Session]) -> str:
head = (f"{'started':<24}{'build':<10}{'sink':<8}{'tier':<15}{'scene':<10}{'dur':>6}"
f"{'fps':>6}{'drop':>6}{'stalls':>7}{'avgRen':>8}{'worstRen':>10}"
f"{'cap/s':>7}{'conv':>6} verdict")
out = [head, "-" * len(head)]
for s in sessions:
cap = mean(s.capture_rates) if s.capture_rates else 0.0
conv = mean(s.capture_conv) if s.capture_conv else 0
out.append(
f"{s.started:<24}{s.build[:8]:<10}{s.sink:<8}{s.tier:<15}{s.scene[:9]:<10}{s.seconds:>5.0f}s"
f"{s.measured_fps:>6.1f}{s.dropped:>6}{s.stalls:>7}{s.avg_render:>8.1f}"
f"{s.worst_render:>10.1f}{cap:>7.1f}{conv:>6.0f} {s.verdict}")
return "\n".join(out)
def summary(sessions: list[Session]) -> str:
groups: dict[tuple[str, str], list[Session]] = {}
for s in sessions:
groups.setdefault((s.tier, s.sink), []).append(s)
out = ["", f"{'tier':<16}{'sink':<9}{'sessions':>9}{'holds':>7}{'strained':>10}{'over':>6}"]
for (tier, sink), rows in sorted(groups.items()):
holds = sum(1 for r in rows if r.verdict == "holds")
strained = sum(1 for r in rows if r.verdict == "strained")
over = sum(1 for r in rows if r.verdict == "over-budget")
out.append(f"{tier:<16}{sink:<9}{len(rows):>9}{holds:>7}{strained:>10}{over:>6}")
return "\n".join(out)
def main() -> int:
ap = argparse.ArgumentParser(description="Per-session performance evidence from LlamaCasty logs.")
ap.add_argument("logs", nargs="*", help="log files (default: every startup*.log under %%APPDATA%%\\ytLlive)")
ap.add_argument("--json", action="store_true", help="machine-readable output")
args = ap.parse_args()
paths = args.logs or default_logs()
if not paths:
print("no logs found", file=sys.stderr)
return 1
sessions = parse(paths)
if args.json:
print(json.dumps([asdict(s) | {"sink": s.sink, "verdict": s.verdict,
"measured_fps": round(s.measured_fps, 2)}
for s in sessions], indent=2))
return 0
print(table(sessions))
print(summary(sessions))
print("\nholds = 0 drops, 0 stalls, worst render under half the frame budget. "
"RECORD and TEST isolate the machine; LIVE adds the network and the audience.")
return 0
if __name__ == "__main__":
raise SystemExit(main())