#!/usr/bin/env python3 """Import the console text, credits, timing and ASCII art for the still_alive demo. Reads index.html and res/stillalive.js from https://github.com/SDSkyKlouD/still-alive-web (downloaded, or local copies) and writes src/still_alive/imported.h, which the firmware uses instead of the placeholder lyrics.h / art.h / credits.h when it exists. python tools/import_still_alive.py # download from GitHub python tools/import_still_alive.py --html index.html --js stillalive.js python tools/import_still_alive.py --out somewhere/imported.h imported.h is committed with a disclaimer and source reference at its top. Source format: each
is one screen (the console clears when it starts). Inside it, are typed lines with times relative to the screen, and
are extra blank lines. ASCII art comes from
 blocks. The credits are the CREDIT_DATA array in
stillalive.js; the page starts typing them 9 s in and starts the song 6.75 s in.
"""

import argparse
import html
import re
import sys
import urllib.request
from pathlib import Path

SOURCE_BASE = "https://raw.githubusercontent.com/SDSkyKlouD/still-alive-web/master/"
OUT = Path(__file__).resolve().parent.parent / "src" / "still_alive" / "imported.h"

# Timing from stillalive.js.
SONG_START_MS = 6750
CREDITS_START_MS = 9000
CREDIT_CHAR_US = 68624  # 68.623562 ms per character

CONSOLE_COLS = 41
CONSOLE_ROWS = 31
ART_COLS = 41
ART_ROWS = 20

# The panel font is plain ASCII; fold the few typographic characters.
FOLD = {"‘": "'", "’": "'", "“": '"', "”": '"',
        "–": "-", "—": "-", "…": "...", " ": " "}


def to_ascii(s):
    s = "".join(FOLD.get(c, c) for c in s)
    return "".join(c if 32 <= ord(c) < 127 else "?" for c in s)


def c_string(s):
    return '"' + s.replace("\\", "\\\\").replace('"', '\\"') + '"'


def attr(tag, name):
    m = re.search(r'\b%s="([^"]*)"' % re.escape(name), tag)
    return html.unescape(m.group(1)) if m else None


def load(path, remote):
    if path:
        return Path(path).read_text(encoding="utf-8")
    with urllib.request.urlopen(SOURCE_BASE + remote) as r:
        return r.read().decode("utf-8")


def parse_credits(script):
    m = re.search(r"const CREDIT_DATA = \[(.*?)\n\];", script, re.S)
    if not m:
        return []
    # Each entry is a double-quoted JS string; undo its escapes.
    return [to_ascii(re.sub(r"\\(.)", r"\1", s))
            for s in re.findall(r'"((?:[^"\\]|\\.)*)"', m.group(1))]


def parse_art(page):
    arts = {}
    for m in re.finditer(r'
(.*?)
', page, re.S): lines = [to_ascii(l.rstrip()) for l in html.unescape(m.group(2)).split("\n")] while lines and not lines[0].strip(): lines.pop(0) while lines and not lines[-1].strip(): lines.pop() arts[m.group(1)] = lines return arts def parse_steps(page, art_ids): # (start, order, dur, flags, art, text); order keeps source order on equal times. steps = [] screens = list(re.finditer(r'
', page)) for i, screen in enumerate(screens): base = int(screen.group(1)) end = screens[i + 1].start() if i + 1 < len(screens) else len(page) body = page[screen.end():end] steps.append((base, len(steps), 0, "STEP_CLEAR", "ART_KEEP", "")) for tag in re.finditer(r"<(span|br)\b[^>]*>", body): t = tag.group(0) if tag.group(1) == "br": offset = attr(t, "data-show-offset") if offset is not None: steps.append((base + int(offset), len(steps), 0, "STEP_NEWLINE", "ART_KEEP", "")) continue text = attr(t, "data-text") if text is None or attr(t, "data-start") is None: continue flags = "STEP_NEWLINE" if attr(t, "data-append-br") == "true" else "0" art = attr(t, "data-asciiart") art_id = "ART_KEEP" if art is None else ("ART_NONE" if art == "clear" else str(art_ids.get(art, 0))) steps.append((base + int(attr(t, "data-start")), len(steps), min(int(attr(t, "data-dur") or 0), 65535), flags, art_id, to_ascii(text))) steps.sort(key=lambda s: (s[0], s[1])) return steps def check(steps, arts): """Warn about anything that will not fit the panel layout.""" rows = 0 col = 0 for start, _, _, flags, _, text in steps: if flags == "STEP_CLEAR": rows, col = 0, 0 col += len(text) if len(text) > CONSOLE_COLS: print(f"warning: line at {start} ms is {len(text)} chars, wraps", file=sys.stderr) if flags == "STEP_NEWLINE": rows += 1 col = 0 if rows == CONSOLE_ROWS: print(f"warning: screen overflows {CONSOLE_ROWS} rows at {start} ms, scrolls", file=sys.stderr) for name, lines in arts.items(): if len(lines) > ART_ROWS or max(map(len, lines), default=0) > ART_COLS: print(f"warning: art '{name}' is larger than {ART_COLS}x{ART_ROWS}, gets cut", file=sys.stderr) def main(): ap = argparse.ArgumentParser(description=__doc__.split("\n")[0]) ap.add_argument("--html", help="local index.html (default: download)") ap.add_argument("--js", help="local res/stillalive.js (default: download)") ap.add_argument("--out", type=Path, default=OUT, help="output file (default: %(default)s)") args = ap.parse_args() page = load(args.html, "index.html") arts = parse_art(page) art_ids = {name: i + 1 for i, name in enumerate(arts)} steps = parse_steps(page, art_ids) credits = parse_credits(load(args.js, "res/stillalive.js")) if not steps or not arts or not credits: sys.exit("error: no lyrics, art or credits found; has the source changed?") check(steps, arts) out = ["// Song text, timing, ASCII art and credits from Portal's ending sequence,", "// imported from still-alive-web by SD SkyKlouD:", "// https://github.com/SDSkyKlouD/still-alive-web", "//", "// Disclaimer: \"Still Alive\" was written by Jonathan Coulton for Portal (2007).", "// Portal, its credits and ASCII art are (c) Valve Corporation, see", "// https://store.steampowered.com/legal/. Included only for personal,", "// non-commercial use in this hobby project; all rights remain with their", "// owners. This project is not affiliated with or endorsed by Valve.", "//", "// Generated by tools/import_still_alive.py - do not edit by hand.", "#pragma once", "", '#include "show_data.h"', ""] for name, lines in arts.items(): out.append(f"static const char* const ART_{name.upper()}_LINES[] = {{") out += [f" {c_string(l)}," for l in lines] out += ["};", ""] out.append("static const Art ARTS[] = {") out.append(" {nullptr, 0},") out += [f" {{ART_{n.upper()}_LINES, {len(l)}}}, // {art_ids[n]}" for n, l in arts.items()] out += ["};", "static constexpr size_t ART_COUNT = sizeof(ARTS) / sizeof(ARTS[0]);", ""] out.append("static const LyricStep LYRICS[] = {") out += [f" {{{s}, {d}, {f}, {a}, {c_string(t)}}}," for s, _, d, f, a, t in steps] out += ["};", "static constexpr size_t LYRIC_COUNT = sizeof(LYRICS) / sizeof(LYRICS[0]);", ""] out.append("static const char* const CREDITS[] = {") out += [f" {c_string(c)}," for c in credits] out += ["};", "static constexpr size_t CREDIT_COUNT = sizeof(CREDITS) / sizeof(CREDITS[0]);", ""] out += [f"static constexpr uint32_t SONG_START_MS = {SONG_START_MS};", f"static constexpr uint32_t CREDITS_START_MS = {CREDITS_START_MS};", f"static constexpr uint32_t CREDIT_CHAR_US = {CREDIT_CHAR_US};", "static constexpr bool CREDITS_LOOP = false;", ""] args.out.write_text("\n".join(out), encoding="utf-8", newline="\n") screens = sum(1 for s in steps if s[3] == "STEP_CLEAR") print(f"wrote {args.out}: {len(steps)} steps on {screens} screens, {len(arts)} pictures, " f"{len(credits)} credit lines, {steps[-1][0] / 1000:.0f} s") if __name__ == "__main__": main()