Public Access
Commit src/still_alive/imported.h instead of ignoring it. The importer now writes a source reference (SDSkyKlouD/still-alive-web) and a disclaimer (Jonathan Coulton / Valve, personal non-commercial use, not affiliated) at the top of the file; the README carries the same disclaimer. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
196 lines
8.3 KiB
Python
196 lines
8.3 KiB
Python
#!/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 <div class="container_lyrics" data-start=ms> is one screen
|
||
(the console clears when it starts). Inside it, <span data-start data-dur
|
||
data-append-br data-text data-asciiart> are typed lines with times relative to
|
||
the screen, and <br data-show-offset=ms> are extra blank lines. ASCII art comes
|
||
from <pre class="asciiart_NAME"> 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'<pre class="asciiart_(\w+)">(.*?)</pre>', 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'<div class="container_lyrics[^"]*" data-start="(\d+)">', 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()
|