Add ESPHome display component; ring-buffer driver shared with PlatformIO

Driver (components/el512/el512_panel.*, ESP-IDF only):
- DMA ring of 8 x 4 lines (17 KB) refilled from the I2S EOF interrupt
  (IRAM) instead of a 142 KB full-frame stream; ~107 KB heap freed.
- 256-entry lookup table encodes 8 pixels per step.
- Back buffer + displayed buffer, swapped at the start of a frame.
- PlatformIO builds it via lib_extra_dirs (EL512Core); lib/EL512 keeps
  the Adafruit GFX front end.

ESPHome external component (platform: el512):
- DisplayBuffer with optional ENABLE / _LOWPWR pins, set_enabled(),
  set_low_power(), helper functions for page lambdas.
- still_alive_page: plays the Portal end-credits show, then returns to
  the previous page.

Still Alive engine moved to components/el512/still_alive.* with a built-in
5x7 font (from Adafruit GFX, BSD); the PlatformIO demo uses the same code.
The importer writes components/el512/still_alive_data.h and stops at 200 s
(--until-ms), matching the hand-trimmed data.

esphome/el512-panel.yaml: 8 pages (clock, forecast, agenda, dashboard,
now playing, Plex, solar, Still Alive), page select, rotation, display
and low-power switches, message text. home_assistant/packages/el512_panel.yaml:
helper template sensors for forecast, agenda, now playing and Plex streams.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mangavalkandClaude Opus 5.5 committed 2026-10-18 20:20:13 +02:00
1 parent 410885de24
commit 963590cdfe
27 files changed
+2110 -828

No files matched your search

+10
View File
@@ -0,0 +1,10 @@
"""Planar EL512.256-H electroluminescent panel (512x256) on an ESP32.
Driven by I2S0 in parallel mode with DMA; see el512_panel.h for the details.
"""
import esphome.codegen as cg
CODEOWNERS = ["@mangavalk"]
el512_ns = cg.esphome_ns.namespace("el512")
+66
View File
@@ -0,0 +1,66 @@
from esphome import pins
import esphome.codegen as cg
from esphome.components import display
import esphome.config_validation as cv
from esphome.const import CONF_ID, CONF_LAMBDA, CONF_PAGES
from . import el512_ns
DEPENDENCIES = ["esp32"]
CONF_VCLK_PIN = "vclk_pin"
CONF_VID_PIN = "vid_pin"
CONF_TVID_PIN = "tvid_pin"
CONF_HS_PIN = "hs_pin"
CONF_VS_PIN = "vs_pin"
CONF_ENABLE_PIN = "enable_pin"
CONF_LOW_POWER_PIN = "low_power_pin"
CONF_STILL_ALIVE_PAGE = "still_alive_page"
EL512Display = el512_ns.class_("EL512Display", display.DisplayBuffer)
# The signals are routed through the GPIO matrix, so any output-capable GPIO
# works; the defaults are the wiring in include/pins.h.
CONFIG_SCHEMA = cv.All(
display.FULL_DISPLAY_SCHEMA.extend(
{
cv.GenerateID(): cv.declare_id(EL512Display),
cv.Optional(CONF_VCLK_PIN, default=18): pins.internal_gpio_output_pin_number,
cv.Optional(CONF_VID_PIN, default=19): pins.internal_gpio_output_pin_number,
cv.Optional(CONF_TVID_PIN, default=21): pins.internal_gpio_output_pin_number,
cv.Optional(CONF_HS_PIN, default=22): pins.internal_gpio_output_pin_number,
cv.Optional(CONF_VS_PIN, default=23): pins.internal_gpio_output_pin_number,
cv.Optional(CONF_ENABLE_PIN): pins.internal_gpio_output_pin_number,
cv.Optional(CONF_LOW_POWER_PIN): pins.internal_gpio_output_pin_number,
# Page (from `pages:`) that plays the Portal end-credits show; its
# lambda should call it.draw_still_alive() via id(...).
cv.Optional(CONF_STILL_ALIVE_PAGE): cv.use_id(display.DisplayPage),
}
).extend(cv.polling_component_schema("1s")),
cv.has_at_most_one_key(CONF_PAGES, CONF_LAMBDA),
)
async def to_code(config):
var = cg.new_Pvariable(config[CONF_ID])
await display.register_display(var, config)
cg.add(
var.set_pins(
config[CONF_VCLK_PIN],
config[CONF_VID_PIN],
config[CONF_TVID_PIN],
config[CONF_HS_PIN],
config[CONF_VS_PIN],
config.get(CONF_ENABLE_PIN, -1),
config.get(CONF_LOW_POWER_PIN, -1),
)
)
if CONF_STILL_ALIVE_PAGE in config:
page = await cg.get_variable(config[CONF_STILL_ALIVE_PAGE])
cg.add(var.set_still_alive_page(page))
if CONF_LAMBDA in config:
lambda_ = await cg.process_lambda(
config[CONF_LAMBDA], [(display.DisplayRef, "it")], return_type=cg.void
)
cg.add(var.set_writer(lambda_))
+110
View File
@@ -0,0 +1,110 @@
#include "el512_display.h"
#include <esp_heap_caps.h>
#include "esphome/core/hal.h"
#include "esphome/core/log.h"
namespace esphome::el512 {
static const char *const TAG = "el512";
void EL512Display::setup() {
this->panel_ = new EL512Panel(this->pins_); // NOLINT(cppcoreguidelines-owning-memory)
if (!this->panel_->begin()) {
ESP_LOGE(TAG, "Panel init failed (out of DMA memory or no free interrupt)");
this->mark_failed();
return;
}
// Apply states that switches may have restored before setup ran.
this->panel_->setLowPower(this->low_power_);
this->panel_->setEnabled(this->enabled_);
}
void EL512Display::loop() {
if (this->still_alive_page_ == nullptr || this->panel_ == nullptr || !this->panel_->running())
return;
const bool active = this->is_still_alive_playing();
const uint32_t now = millis();
if (active && !this->still_alive_was_active_) {
// Remember where we came from, then start the show from the top.
this->still_alive_return_ = this->previous_page_ != this->still_alive_page_ ? this->previous_page_ : nullptr;
this->still_alive_.start(now);
ESP_LOGI(TAG, "Still Alive started; start the song in %u ms", (unsigned) this->still_alive_song_start_ms());
}
this->still_alive_was_active_ = active;
if (!active)
return;
if (this->still_alive_.finished(now) && this->still_alive_return_ != nullptr) {
this->show_page(this->still_alive_return_);
this->update();
return;
}
if (this->still_alive_.update(now))
this->update();
}
void EL512Display::play_still_alive() {
if (this->still_alive_page_ == nullptr)
return;
if (this->is_still_alive_playing()) {
this->still_alive_.start(millis()); // already showing: just restart
} else {
this->show_page(this->still_alive_page_); // loop() starts it
}
}
void EL512Display::draw_still_alive() {
if (this->panel_ != nullptr)
this->still_alive_.draw(*this->panel_);
}
void EL512Display::update() {
if (this->panel_ == nullptr || !this->panel_->running())
return;
this->do_update_();
this->panel_->show();
}
void EL512Display::fill(Color color) {
// The panel clear is a memset; with clipping active, fall back to per-pixel.
if (this->panel_ == nullptr || this->get_clipping().is_set()) {
Display::fill(color);
return;
}
this->panel_->clear(color.is_on());
}
void EL512Display::draw_absolute_pixel_internal(int x, int y, Color color) {
if (this->panel_ != nullptr)
this->panel_->setPixel(x, y, color.is_on());
}
void EL512Display::set_enabled(bool on) {
this->enabled_ = on;
if (this->panel_ != nullptr)
this->panel_->setEnabled(on);
}
void EL512Display::set_low_power(bool on) {
this->low_power_ = on;
if (this->panel_ != nullptr)
this->panel_->setLowPower(on);
}
void EL512Display::dump_config() {
LOG_DISPLAY("", "EL512.256-H", this);
ESP_LOGCONFIG(TAG,
" Pins: VCLK %d, VID %d, TVID %d, HS %d, VS %d, ENABLE %d, _LOWPWR %d\n"
" Refresh: %.1f Hz, DMA ring %u bytes\n"
" Free internal heap: %u bytes",
this->pins_.vclk, this->pins_.vid, this->pins_.tvid, this->pins_.hs, this->pins_.vs,
this->pins_.enable, this->pins_.lowpwr_n, EL512Panel::refreshHz(),
(unsigned) EL512Panel::RING_BYTES,
(unsigned) heap_caps_get_free_size(MALLOC_CAP_INTERNAL));
LOG_UPDATE_INTERVAL(this);
if (this->is_failed())
ESP_LOGE(TAG, " Init failed");
}
} // namespace esphome::el512
+68
View File
@@ -0,0 +1,68 @@
#pragma once
#include "el512_helpers.h"
#include "el512_panel.h"
#include "esphome/components/display/display_buffer.h"
#include "esphome/core/component.h"
#include "still_alive.h"
namespace esphome::el512 {
// ESPHome display for the Planar EL512.256-H. Drawing goes into the panel
// driver's back buffer; every update() hands it to the refresh in one piece.
class EL512Display : public display::DisplayBuffer {
public:
void set_pins(int vclk, int vid, int tvid, int hs, int vs, int enable, int low_power) {
this->pins_ = {(int8_t) vclk, (int8_t) vid, (int8_t) tvid, (int8_t) hs,
(int8_t) vs, (int8_t) enable, (int8_t) low_power};
}
void setup() override;
void loop() override;
void update() override;
void dump_config() override;
float get_setup_priority() const override { return setup_priority::PROCESSOR; }
void fill(Color color) override;
display::DisplayType get_display_type() override { return display::DisplayType::DISPLAY_TYPE_BINARY; }
// Display on/off (ENABLE) and low-power mode (_LOWPWR), e.g. from template switches.
void set_enabled(bool on);
void set_low_power(bool on);
bool is_enabled() const { return this->enabled_; }
bool is_low_power() const { return this->low_power_; }
bool has_enable_pin() const { return this->pins_.enable >= 0; }
bool has_low_power_pin() const { return this->pins_.lowpwr_n >= 0; }
// Portal end-credits show. Showing this page restarts it; while it is the
// active page the display redraws as the show advances, and when it ends the
// previous page comes back. Draw it from that page's lambda with
// draw_still_alive().
void set_still_alive_page(display::DisplayPage *page) { this->still_alive_page_ = page; }
void draw_still_alive();
// Show the Still Alive page and start the show from the top (also when it is
// already playing).
void play_still_alive();
bool is_still_alive_playing() const {
return this->still_alive_page_ != nullptr && this->get_active_page() == this->still_alive_page_;
}
// When to start the music, in ms after the page was shown.
uint32_t still_alive_song_start_ms() const { return el512sa::StillAlivePlayer::songStartMs(); }
protected:
void draw_absolute_pixel_internal(int x, int y, Color color) override;
int get_width_internal() override { return EL512Panel::WIDTH; }
int get_height_internal() override { return EL512Panel::HEIGHT; }
EL512Pins pins_{18, 19, 21, 22, 23, -1, -1};
EL512Panel *panel_{nullptr};
bool enabled_{true};
bool low_power_{false};
display::DisplayPage *still_alive_page_{nullptr};
display::DisplayPage *still_alive_return_{nullptr};
bool still_alive_was_active_{false};
el512sa::StillAlivePlayer still_alive_;
};
} // namespace esphome::el512
+134
View File
@@ -0,0 +1,134 @@
// Classic 5x7 console font (printable ASCII 32..126), drawn in a 6x8 cell.
// Column-major, 5 bytes per character, bit 0 = top row.
//
// Taken from glcdfont.c of the Adafruit GFX Library
// (https://github.com/adafruit/Adafruit-GFX-Library), license below.
//
// Software License Agreement (BSD License)
//
// Copyright (c) 2012 Adafruit Industries. All rights reserved.
//
// Redistribution and use in source and binary forms, with or without
// modification, are permitted provided that the following conditions are met:
//
// - Redistributions of source code must retain the above copyright notice,
// this list of conditions and the following disclaimer.
// - Redistributions in binary form must reproduce the above copyright notice,
// this list of conditions and the following disclaimer in the documentation
// and/or other materials provided with the distribution.
//
// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
// AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
// IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
// ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
// LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
// CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
// SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
// INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
// CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
// ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
// POSSIBILITY OF SUCH DAMAGE.
#pragma once
#include <stdint.h>
static constexpr char EL512_FONT_FIRST = 32;
static constexpr char EL512_FONT_LAST = 126;
static const uint8_t EL512_FONT[] = {
0x00, 0x00, 0x00, 0x00, 0x00, // space
0x00, 0x00, 0x5F, 0x00, 0x00, // !
0x00, 0x07, 0x00, 0x07, 0x00, // "
0x14, 0x7F, 0x14, 0x7F, 0x14, // #
0x24, 0x2A, 0x7F, 0x2A, 0x12, // $
0x23, 0x13, 0x08, 0x64, 0x62, // %
0x36, 0x49, 0x56, 0x20, 0x50, // &
0x00, 0x08, 0x07, 0x03, 0x00, // '
0x00, 0x1C, 0x22, 0x41, 0x00, // (
0x00, 0x41, 0x22, 0x1C, 0x00, // )
0x2A, 0x1C, 0x7F, 0x1C, 0x2A, // *
0x08, 0x08, 0x3E, 0x08, 0x08, // +
0x00, 0x80, 0x70, 0x30, 0x00, // ,
0x08, 0x08, 0x08, 0x08, 0x08, // -
0x00, 0x00, 0x60, 0x60, 0x00, // .
0x20, 0x10, 0x08, 0x04, 0x02, // /
0x3E, 0x51, 0x49, 0x45, 0x3E, // 0
0x00, 0x42, 0x7F, 0x40, 0x00, // 1
0x72, 0x49, 0x49, 0x49, 0x46, // 2
0x21, 0x41, 0x49, 0x4D, 0x33, // 3
0x18, 0x14, 0x12, 0x7F, 0x10, // 4
0x27, 0x45, 0x45, 0x45, 0x39, // 5
0x3C, 0x4A, 0x49, 0x49, 0x31, // 6
0x41, 0x21, 0x11, 0x09, 0x07, // 7
0x36, 0x49, 0x49, 0x49, 0x36, // 8
0x46, 0x49, 0x49, 0x29, 0x1E, // 9
0x00, 0x00, 0x14, 0x00, 0x00, // :
0x00, 0x40, 0x34, 0x00, 0x00, // ;
0x00, 0x08, 0x14, 0x22, 0x41, // <
0x14, 0x14, 0x14, 0x14, 0x14, // =
0x00, 0x41, 0x22, 0x14, 0x08, // >
0x02, 0x01, 0x59, 0x09, 0x06, // ?
0x3E, 0x41, 0x5D, 0x59, 0x4E, // @
0x7C, 0x12, 0x11, 0x12, 0x7C, // A
0x7F, 0x49, 0x49, 0x49, 0x36, // B
0x3E, 0x41, 0x41, 0x41, 0x22, // C
0x7F, 0x41, 0x41, 0x41, 0x3E, // D
0x7F, 0x49, 0x49, 0x49, 0x41, // E
0x7F, 0x09, 0x09, 0x09, 0x01, // F
0x3E, 0x41, 0x41, 0x51, 0x73, // G
0x7F, 0x08, 0x08, 0x08, 0x7F, // H
0x00, 0x41, 0x7F, 0x41, 0x00, // I
0x20, 0x40, 0x41, 0x3F, 0x01, // J
0x7F, 0x08, 0x14, 0x22, 0x41, // K
0x7F, 0x40, 0x40, 0x40, 0x40, // L
0x7F, 0x02, 0x1C, 0x02, 0x7F, // M
0x7F, 0x04, 0x08, 0x10, 0x7F, // N
0x3E, 0x41, 0x41, 0x41, 0x3E, // O
0x7F, 0x09, 0x09, 0x09, 0x06, // P
0x3E, 0x41, 0x51, 0x21, 0x5E, // Q
0x7F, 0x09, 0x19, 0x29, 0x46, // R
0x26, 0x49, 0x49, 0x49, 0x32, // S
0x03, 0x01, 0x7F, 0x01, 0x03, // T
0x3F, 0x40, 0x40, 0x40, 0x3F, // U
0x1F, 0x20, 0x40, 0x20, 0x1F, // V
0x3F, 0x40, 0x38, 0x40, 0x3F, // W
0x63, 0x14, 0x08, 0x14, 0x63, // X
0x03, 0x04, 0x78, 0x04, 0x03, // Y
0x61, 0x59, 0x49, 0x4D, 0x43, // Z
0x00, 0x7F, 0x41, 0x41, 0x41, // [
0x02, 0x04, 0x08, 0x10, 0x20, // backslash
0x00, 0x41, 0x41, 0x41, 0x7F, // ]
0x04, 0x02, 0x01, 0x02, 0x04, // ^
0x40, 0x40, 0x40, 0x40, 0x40, // _
0x00, 0x03, 0x07, 0x08, 0x00, // `
0x20, 0x54, 0x54, 0x78, 0x40, // a
0x7F, 0x28, 0x44, 0x44, 0x38, // b
0x38, 0x44, 0x44, 0x44, 0x28, // c
0x38, 0x44, 0x44, 0x28, 0x7F, // d
0x38, 0x54, 0x54, 0x54, 0x18, // e
0x00, 0x08, 0x7E, 0x09, 0x02, // f
0x18, 0xA4, 0xA4, 0x9C, 0x78, // g
0x7F, 0x08, 0x04, 0x04, 0x78, // h
0x00, 0x44, 0x7D, 0x40, 0x00, // i
0x20, 0x40, 0x40, 0x3D, 0x00, // j
0x7F, 0x10, 0x28, 0x44, 0x00, // k
0x00, 0x41, 0x7F, 0x40, 0x00, // l
0x7C, 0x04, 0x78, 0x04, 0x78, // m
0x7C, 0x08, 0x04, 0x04, 0x78, // n
0x38, 0x44, 0x44, 0x44, 0x38, // o
0xFC, 0x18, 0x24, 0x24, 0x18, // p
0x18, 0x24, 0x24, 0x18, 0xFC, // q
0x7C, 0x08, 0x04, 0x04, 0x08, // r
0x48, 0x54, 0x54, 0x54, 0x24, // s
0x04, 0x04, 0x3F, 0x44, 0x24, // t
0x3C, 0x40, 0x40, 0x20, 0x7C, // u
0x1C, 0x20, 0x40, 0x20, 0x1C, // v
0x3C, 0x40, 0x30, 0x40, 0x3C, // w
0x44, 0x28, 0x10, 0x28, 0x44, // x
0x4C, 0x90, 0x90, 0x90, 0x7C, // y
0x44, 0x64, 0x54, 0x4C, 0x44, // z
0x00, 0x08, 0x36, 0x41, 0x00, // {
0x00, 0x00, 0x77, 0x00, 0x00, // |
0x00, 0x41, 0x36, 0x08, 0x00, // }
0x02, 0x01, 0x02, 0x04, 0x02, // ~
};
+108
View File
@@ -0,0 +1,108 @@
// Small helpers for page lambdas (available as el512::... in YAML lambdas).
#pragma once
#include <cstdio>
#include <string>
#include <vector>
#include "esphome/components/display/display.h"
namespace esphome::el512 {
// Split the "a|b|c;d|e|f" lists made by the Home Assistant package.
inline std::vector<std::string> split(const std::string &s, char sep) {
std::vector<std::string> out;
if (s.empty())
return out;
size_t start = 0;
while (true) {
const size_t end = s.find(sep, start);
out.push_back(s.substr(start, end == std::string::npos ? std::string::npos : end - start));
if (end == std::string::npos)
return out;
start = end + 1;
}
}
// Field i of a "|"-separated entry, or "" if missing.
inline std::string field(const std::string &entry, size_t i) {
const auto parts = split(entry, '|');
return i < parts.size() ? parts[i] : std::string();
}
// Material Design Icons glyph (UTF-8) for a Home Assistant weather condition.
inline const char *weather_icon(const std::string &c) {
if (c == "clear-night") return "\U000F0594";
if (c == "cloudy") return "\U000F0590";
if (c == "fog") return "\U000F0591";
if (c == "hail") return "\U000F0592";
if (c == "lightning") return "\U000F0593";
if (c == "lightning-rainy") return "\U000F067E";
if (c == "partlycloudy") return "\U000F0595";
if (c == "pouring") return "\U000F0596";
if (c == "rainy") return "\U000F0597";
if (c == "snowy") return "\U000F0598";
if (c == "snowy-rainy") return "\U000F067F";
if (c == "sunny") return "\U000F0599";
if (c == "windy") return "\U000F059D";
if (c == "windy-variant") return "\U000F059E";
return "\U000F05D6"; // exceptional / unknown
}
// Readable weather condition ("partlycloudy" -> "Partly cloudy").
inline std::string weather_text(const std::string &c) {
if (c == "clear-night") return "Clear";
if (c == "partlycloudy") return "Partly cloudy";
if (c == "lightning-rainy") return "Thunderstorm";
if (c == "snowy-rainy") return "Sleet";
if (c == "windy-variant") return "Windy";
if (c == "exceptional") return "Exceptional";
if (c.empty()) return "";
std::string s = c;
s[0] = (char) toupper(s[0]);
return s;
}
// Cut text with "..." so it fits in max_width pixels in the given font.
inline std::string fit(display::BaseFont *font, const std::string &text, int max_width) {
int w, x_off, baseline, h;
font->measure(text.c_str(), &w, &x_off, &baseline, &h);
if (w <= max_width)
return text;
std::string s = text;
while (!s.empty()) {
// Drop a whole UTF-8 character.
do {
s.pop_back();
} while (!s.empty() && (s.back() & 0xC0) == 0x80);
const std::string t = s + "...";
font->measure(t.c_str(), &w, &x_off, &baseline, &h);
if (w <= max_width)
return t;
}
return "...";
}
// 245 -> "4:05", 3725 -> "1:02:05".
inline std::string mmss(int seconds) {
if (seconds < 0)
seconds = 0;
char buf[16];
if (seconds >= 3600)
snprintf(buf, sizeof(buf), "%d:%02d:%02d", seconds / 3600, seconds / 60 % 60, seconds % 60);
else
snprintf(buf, sizeof(buf), "%d:%02d", seconds / 60, seconds % 60);
return buf;
}
// 1250 -> "1250 W", 4200 -> "4.20 kW".
inline std::string watts(float w) {
char buf[16];
if (w >= 1000 || w <= -1000)
snprintf(buf, sizeof(buf), "%.2f kW", w / 1000.0f);
else
snprintf(buf, sizeof(buf), "%.0f W", w);
return buf;
}
} // namespace esphome::el512
+335
View File
@@ -0,0 +1,335 @@
#include "el512_panel.h"
#include <driver/gpio.h>
#include <esp_attr.h>
#include <esp_heap_caps.h>
#include <esp_intr_alloc.h>
#include <esp_rom_gpio.h>
#include <freertos/FreeRTOS.h>
#include <freertos/task.h>
#include <soc/gpio_sig_map.h>
#include <soc/i2s_reg.h>
#include <soc/i2s_struct.h>
#include <soc/periph_defs.h>
#include <string.h>
#if __has_include(<esp_private/periph_ctrl.h>)
#include <esp_private/periph_ctrl.h>
#else
#include <driver/periph_ctrl.h>
#endif
#if __has_include(<esp32/rom/lldesc.h>)
#include <esp32/rom/lldesc.h>
#else
#include <rom/lldesc.h>
#endif
namespace {
// I2S0 clock source when clka_en = 0: PLL_D2_CLK, 160 MHz whatever the CPU clock.
constexpr uint32_t I2S_SOURCE_CLOCK_HZ = 160000000;
constexpr uint32_t I2S_BYTE_RATE_HZ = EL512Panel::PIXEL_CLOCK_HZ * EL512Panel::BYTES_PER_CLOCK;
constexpr uint32_t I2S_CLKM_DIV = I2S_SOURCE_CLOCK_HZ / I2S_BYTE_RATE_HZ;
static_assert(I2S_SOURCE_CLOCK_HZ % I2S_BYTE_RATE_HZ == 0,
"byte rate must divide 160 MHz exactly");
static_assert(I2S_CLKM_DIV >= 2 && I2S_CLKM_DIV <= 255, "clkm_div_num out of range");
// In 8-bit LCD mode I2S0 puts its byte on DATA_OUT16..23 (bitluni ESP32Lib).
constexpr uint32_t I2S0_DATA_SIGNAL_BASE = I2S0O_DATA_OUT16_IDX;
// BCK runs at half the byte rate (measured: 2.5 MHz BCK at 5 MHz bytes), so each
// VCLK period spans two identical bytes. Uninverted, the rising edge landed on the
// byte-pair boundary and the image jittered; inverted, it sits mid-pair (verified
// on the panel).
constexpr bool INVERT_VCLK = true;
static_assert(EL512Panel::LINES_PER_CHUNK * EL512Panel::ROW_BYTES <= 4092, "descriptor too long");
static_assert(EL512Panel::ROW_BYTES % 4 == 0, "rows must be whole DMA words");
static_assert(EL512Panel::RING_LINES < EL512Panel::LINES_PER_FRAME, "ring longer than a frame");
constexpr uint16_t FB_ROW_BYTES = EL512Panel::WIDTH / 8;
constexpr uint16_t DATA_BYTES = EL512Panel::DATA_CLOCKS_PER_ROW * EL512Panel::BYTES_PER_CLOCK;
// One framebuffer byte (8 pixels = 4 clocks) -> 8 stream bytes as two DMA words,
// already in I2S byte order. Built once in begin(); read from the ISR.
DRAM_ATTR uint32_t s_lut[256][2];
void buildLut() {
for (int b = 0; b < 256; b++) {
uint8_t bytes[8];
for (int c = 0; c < 4; c++) {
// Clock c carries x = 2c and 2c+1. The datasheet numbers columns from 1, so
// x = 2c is column 2c+1 (odd -> TVID) and x = 2c+1 is column 2c+2 (even -> VID).
const uint8_t bits = (uint8_t)(b << (c * 2));
uint8_t v = EL512Panel::BIT_HS;
if (bits & 0x80) v |= EL512Panel::BIT_TVID;
if (bits & 0x40) v |= EL512Panel::BIT_VID;
bytes[(2 * c) ^ 2] = v;
bytes[(2 * c + 1) ^ 2] = v;
}
memcpy(s_lut[b], bytes, sizeof(bytes));
}
}
inline uint32_t IRAM_ATTR repeat4(uint8_t v) { return v * 0x01010101u; }
void resetI2S0() {
const uint32_t lcReset = I2S_IN_RST_M | I2S_OUT_RST_M | I2S_AHBM_RST_M | I2S_AHBM_FIFO_RST_M;
I2S0.lc_conf.val |= lcReset;
I2S0.lc_conf.val &= ~lcReset;
const uint32_t confReset =
I2S_RX_RESET_M | I2S_RX_FIFO_RESET_M | I2S_TX_RESET_M | I2S_TX_FIFO_RESET_M;
I2S0.conf.val |= confReset;
I2S0.conf.val &= ~confReset;
}
void routeOutput(int8_t pin, uint32_t signal, bool invert) {
if (pin < 0) return;
esp_rom_gpio_pad_select_gpio(pin);
gpio_set_direction((gpio_num_t)pin, GPIO_MODE_OUTPUT);
esp_rom_gpio_connect_out_signal(pin, signal, invert, false);
}
void setupGpioOutput(int8_t pin, bool level) {
if (pin < 0) return;
esp_rom_gpio_pad_select_gpio(pin);
gpio_set_direction((gpio_num_t)pin, GPIO_MODE_OUTPUT);
gpio_set_level((gpio_num_t)pin, level);
}
} // namespace
EL512Panel::EL512Panel(const EL512Pins& pins) : _pins(pins) {}
EL512Panel::~EL512Panel() { end(); }
bool EL512Panel::begin() {
if (_running) return true;
setupGpioOutput(_pins.enable, false);
setupGpioOutput(_pins.lowpwr_n, true);
_enabled = false;
_lowPower = false;
buildLut();
// Both framebuffers are read from the ISR, so they must be internal RAM.
_fb = (uint8_t*)heap_caps_calloc(1, FRAMEBUFFER_BYTES, MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT);
_front = (uint8_t*)heap_caps_calloc(1, FRAMEBUFFER_BYTES, MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT);
_ring = (uint8_t*)heap_caps_malloc(RING_BYTES, MALLOC_CAP_DMA | MALLOC_CAP_8BIT);
if (!_fb || !_front || !_ring || !startI2S()) {
end();
return false;
}
_running = true;
setEnabled(true);
return true;
}
void EL512Panel::end() {
if (_pins.enable >= 0) gpio_set_level((gpio_num_t)_pins.enable, 0);
_enabled = false;
stopI2S();
_running = false;
heap_caps_free(_fb);
_fb = nullptr;
heap_caps_free(_front);
_front = nullptr;
heap_caps_free(_ring);
_ring = nullptr;
}
void EL512Panel::setEnabled(bool on) {
_enabled = on;
if (_pins.enable >= 0) gpio_set_level((gpio_num_t)_pins.enable, on);
}
void EL512Panel::setLowPower(bool on) {
_lowPower = on;
if (_pins.lowpwr_n >= 0) gpio_set_level((gpio_num_t)_pins.lowpwr_n, !on);
}
void EL512Panel::setPixel(int16_t x, int16_t y, bool on) {
if (!_fb || x < 0 || y < 0 || x >= WIDTH || y >= HEIGHT) return;
uint8_t& b = _fb[(y * WIDTH + x) >> 3];
const uint8_t mask = 0x80 >> (x & 7);
if (on)
b |= mask;
else
b &= ~mask;
}
bool EL512Panel::getPixel(int16_t x, int16_t y) const {
if (!_fb || x < 0 || y < 0 || x >= WIDTH || y >= HEIGHT) return false;
return _fb[(y * WIDTH + x) >> 3] & (0x80 >> (x & 7));
}
void EL512Panel::clear(bool on) {
if (_fb) memset(_fb, on ? 0xFF : 0x00, FRAMEBUFFER_BYTES);
}
void EL512Panel::show() {
if (!_fb || !_front) return;
if (!_running) {
memcpy(_front, _fb, FRAMEBUFFER_BYTES);
return;
}
// The ISR copies _fb to _front just before it encodes line 0 of a frame.
_swapPending = true;
for (int i = 0; i < 40 && _swapPending; i++) vTaskDelay(1);
}
void IRAM_ATTR EL512Panel::encodeRow(uint16_t line, uint8_t* out) const {
// VS is held high during the last blanking line so that the first HS high
// time ending after its rising edge is row 0 (datasheet: Video Data Input).
const uint32_t vs = repeat4(line == LINES_PER_FRAME - 1 ? BIT_VS : 0);
uint32_t* o = (uint32_t*)out;
if (line >= HEIGHT) {
for (uint16_t i = 0; i < ROW_BYTES / 4; i++) o[i] = vs;
return;
}
const uint8_t* src = _front + (size_t)line * FB_ROW_BYTES;
for (uint16_t k = 0; k < FB_ROW_BYTES; k++) {
o[2 * k] = s_lut[src[k]][0];
o[2 * k + 1] = s_lut[src[k]][1];
}
// Blanking clocks: HS low. Uniform and word-aligned, so no byte swap needed.
for (uint16_t i = DATA_BYTES / 4; i < ROW_BYTES / 4; i++) o[i] = vs;
}
void IRAM_ATTR EL512Panel::fillChunk(uint16_t chunk) {
uint16_t line = _chunkLine[chunk];
uint8_t* out = _ring + (size_t)chunk * LINES_PER_CHUNK * ROW_BYTES;
for (uint16_t i = 0; i < LINES_PER_CHUNK; i++) {
if (line == 0 && _swapPending) {
const uint32_t* s = (const uint32_t*)_fb;
uint32_t* d = (uint32_t*)_front;
for (size_t w = 0; w < FRAMEBUFFER_BYTES / 4; w++) d[w] = s[w];
_swapPending = false;
}
encodeRow(line, out + (size_t)i * ROW_BYTES);
line = (line + 1 == LINES_PER_FRAME) ? 0 : line + 1;
}
}
void IRAM_ATTR EL512Panel::isr(void* arg) {
auto* self = static_cast<EL512Panel*>(arg);
const uint32_t status = I2S0.int_st.val;
I2S0.int_clr.val = status;
if (!(status & I2S_OUT_EOF_INT_ST_M)) return;
// The descriptor that just finished plays again RING_LINES lines later.
const lldesc_t* done = (const lldesc_t*)I2S0.out_eof_des_addr;
const uint16_t chunk = done - (const lldesc_t*)self->_desc;
if (chunk >= CHUNK_COUNT) return;
uint16_t next = self->_chunkLine[chunk] + RING_LINES;
if (next >= LINES_PER_FRAME) next -= LINES_PER_FRAME;
self->_chunkLine[chunk] = next;
self->fillChunk(chunk);
}
bool EL512Panel::startI2S() {
_desc = (lldesc_t*)heap_caps_calloc(CHUNK_COUNT, sizeof(lldesc_t), MALLOC_CAP_DMA);
if (!_desc) return false;
for (uint16_t i = 0; i < CHUNK_COUNT; i++) {
_chunkLine[i] = i * LINES_PER_CHUNK;
fillChunk(i);
lldesc_t& d = _desc[i];
d.size = LINES_PER_CHUNK * ROW_BYTES;
d.length = d.size;
d.owner = 1;
d.eof = 1; // EOF interrupt after every chunk
d.buf = _ring + (size_t)i * LINES_PER_CHUNK * ROW_BYTES;
d.qe.stqe_next = &_desc[(i + 1) % CHUNK_COUNT];
}
periph_module_enable(PERIPH_I2S0_MODULE);
resetI2S0();
// 8-bit parallel LCD mode (bitluni ESP32Lib setup; the i ^ 2 byte order in
// the lookup table depends on these FIFO/channel modes).
I2S0.conf2.val = 0;
I2S0.conf2.lcd_en = 1;
I2S0.conf2.lcd_tx_wrx2_en = 1;
I2S0.conf2.lcd_tx_sdx2_en = 0;
I2S0.sample_rate_conf.val = 0;
I2S0.sample_rate_conf.tx_bits_mod = 8;
I2S0.sample_rate_conf.tx_bck_div_num = 1;
// PLL_D2_CLK / (N + b/a) with b = 0: 160 MHz / 16 = 10 MHz bytes -> 5 MHz BCK.
I2S0.clkm_conf.val = 0;
I2S0.clkm_conf.clka_en = 0;
I2S0.clkm_conf.clkm_div_num = I2S_CLKM_DIV;
I2S0.clkm_conf.clkm_div_a = 1;
I2S0.clkm_conf.clkm_div_b = 0;
I2S0.fifo_conf.val = 0;
I2S0.fifo_conf.tx_fifo_mod_force_en = 1;
I2S0.fifo_conf.tx_fifo_mod = 1;
I2S0.fifo_conf.tx_data_num = 32;
I2S0.fifo_conf.dscr_en = 1;
I2S0.conf1.val = 0;
I2S0.conf1.tx_stop_en = 0;
I2S0.conf1.tx_pcm_bypass = 1;
I2S0.conf_chan.val = 0;
I2S0.conf_chan.tx_chan_mod = 1;
I2S0.conf.tx_right_first = 1;
I2S0.conf.tx_msb_right = 0;
I2S0.conf.tx_msb_shift = 0;
I2S0.conf.tx_mono = 0;
I2S0.conf.tx_short_sync = 0;
I2S0.timing.val = 0;
// Stream bit n -> DATA_OUT(16 + n); BCK -> VCLK.
const int8_t dataPins[] = {_pins.vid, _pins.tvid, _pins.hs, _pins.vs};
for (uint32_t bit = 0; bit < sizeof(dataPins); bit++) {
routeOutput(dataPins[bit], I2S0_DATA_SIGNAL_BASE + bit, false);
}
routeOutput(_pins.vclk, I2S0O_BCK_OUT_IDX, INVERT_VCLK);
// EOF interrupt refills the ring. IRAM so it keeps running during flash writes.
I2S0.int_ena.val = 0;
I2S0.int_clr.val = I2S0.int_raw.val;
if (esp_intr_alloc(ETS_I2S0_INTR_SOURCE, ESP_INTR_FLAG_IRAM, &EL512Panel::isr, this,
&_intr) != ESP_OK) {
_intr = nullptr;
return false;
}
I2S0.int_ena.out_eof = 1;
// Start the DMA; it loops over the chain until stopI2S().
I2S0.lc_conf.val = I2S_OUT_DATA_BURST_EN_M | I2S_OUTDSCR_BURST_EN_M;
I2S0.out_link.addr = (uint32_t)&_desc[0];
I2S0.out_link.start = 1;
I2S0.conf.tx_start = 1;
return true;
}
void EL512Panel::stopI2S() {
if (!_desc) return; // never started
I2S0.int_ena.val = 0;
if (_intr) {
esp_intr_free(_intr);
_intr = nullptr;
}
I2S0.conf.tx_start = 0;
I2S0.out_link.stop = 1;
resetI2S0();
periph_module_disable(PERIPH_I2S0_MODULE);
// Hand the pins back to GPIO and park them low.
const int8_t pins[] = {_pins.vclk, _pins.vid, _pins.tvid, _pins.hs, _pins.vs};
for (int8_t pin : pins) {
if (pin < 0) continue;
esp_rom_gpio_connect_out_signal(pin, SIG_GPIO_OUT_IDX, false, false);
gpio_set_level((gpio_num_t)pin, 0);
}
heap_caps_free(_desc);
_desc = nullptr;
}
+128
View File
@@ -0,0 +1,128 @@
// EL512Panel - refresh driver for the Planar EL512.256-H on an ESP32.
//
// Uses only ESP-IDF APIs, so the same code builds for Arduino/PlatformIO
// (lib/EL512) and for the ESPHome component in this folder.
//
// The panel has no frame memory: it must be refreshed continuously. I2S0 in
// 8-bit parallel (LCD) mode streams the signals by DMA from a small ring of
// encoded lines (CHUNK_COUNT chunks of LINES_PER_CHUNK lines). When a chunk has
// been sent, the DMA EOF interrupt re-encodes it with the lines that come
// RING_LINES later, so the ring stays ~1.7 ms ahead of the panel.
//
// byte bit 0 = VID (even columns 2..512 = x 1, 3, ..., 511)
// byte bit 1 = TVID (odd columns 1..511 = x 0, 2, ..., 510)
// byte bit 2 = HS (high while the 256 data clocks of a row are sent)
// byte bit 3 = VS (marks the top row)
// I2S BCK -> VCLK (inverted, see the .cpp)
//
// BCK rises once per two stream bytes (measured with PCNT), so every VCLK
// period is two identical bytes; the I2S byte rate is 10 MHz for a 5 MHz VCLK.
// In 8-bit LCD mode the I2S emits each 32-bit DMA word as bytes 2, 3, 0, 1, so
// stream byte i is stored at index i ^ 2.
//
// Panel in two-bit parallel mode (PS1 jumper 2 removed, datasheet Table 3):
// 272 clocks per row (256 data + 16 blanking), 261 rows per frame
// (256 visible + 5 blanking) -> 5e6 / (272 * 261) ~= 70.4 Hz.
//
// Drawing goes into a back buffer; show() copies it to the displayed buffer at
// the start of the next frame, so a redraw never shows half-drawn.
#pragma once
#include <stddef.h>
#include <stdint.h>
struct lldesc_s; // ESP32 DMA descriptor
struct intr_handle_data_t; // esp_intr_alloc handle
struct EL512Pins {
int8_t vclk;
int8_t vid;
int8_t tvid;
int8_t hs;
int8_t vs;
int8_t enable; // -1 if not connected (panel pulls it high)
int8_t lowpwr_n; // -1 if not connected (panel pulls it high)
};
class EL512Panel {
public:
static constexpr uint16_t WIDTH = 512;
static constexpr uint16_t HEIGHT = 256;
// Timing (two-bit parallel mode)
static constexpr uint32_t PIXEL_CLOCK_HZ = 5000000;
static constexpr uint16_t DATA_CLOCKS_PER_ROW = WIDTH / 2; // 2 px per VCLK
static constexpr uint16_t CLOCKS_PER_ROW = 272;
static constexpr uint16_t BLANK_CLOCKS_PER_ROW = CLOCKS_PER_ROW - DATA_CLOCKS_PER_ROW;
static constexpr uint16_t LINES_PER_FRAME = 261;
static constexpr uint16_t BLANK_LINES = LINES_PER_FRAME - HEIGHT;
// DMA stream byte bits
static constexpr uint8_t BIT_VID = 1 << 0;
static constexpr uint8_t BIT_TVID = 1 << 1;
static constexpr uint8_t BIT_HS = 1 << 2;
static constexpr uint8_t BIT_VS = 1 << 3;
// Stream bytes per VCLK period (BCK runs at half the I2S byte rate).
static constexpr uint8_t BYTES_PER_CLOCK = 2;
static constexpr uint16_t ROW_BYTES = CLOCKS_PER_ROW * BYTES_PER_CLOCK;
// DMA ring: CHUNK_COUNT descriptors of LINES_PER_CHUNK lines each.
static constexpr uint16_t LINES_PER_CHUNK = 4;
static constexpr uint16_t CHUNK_COUNT = 8;
static constexpr uint16_t RING_LINES = LINES_PER_CHUNK * CHUNK_COUNT;
static constexpr size_t RING_BYTES = (size_t)RING_LINES * ROW_BYTES;
static constexpr size_t FRAMEBUFFER_BYTES = (size_t)WIDTH * HEIGHT / 8;
explicit EL512Panel(const EL512Pins& pins);
~EL512Panel();
// Allocates buffers, sets up control GPIOs and starts the I2S DMA refresh.
bool begin();
void end();
bool running() const { return _running; }
// Display on/off (ENABLE) and low-power mode (_LOWPWR, active low).
void setEnabled(bool on);
void setLowPower(bool on);
bool enabled() const { return _enabled; }
bool lowPower() const { return _lowPower; }
// 1 bpp drawing buffer, row-major, MSB = leftmost pixel.
uint8_t* framebuffer() { return _fb; }
void setPixel(int16_t x, int16_t y, bool on);
bool getPixel(int16_t x, int16_t y) const;
void clear(bool on = false);
// Hand the drawing buffer to the refresh. Copies it at the start of the next
// frame and waits for that (at most one frame, ~14 ms).
void show();
// Encode one scan line (0..LINES_PER_FRAME-1) of the displayed buffer into
// ROW_BYTES bytes in DMA order. Lines >= HEIGHT produce blanking.
void encodeRow(uint16_t line, uint8_t* out) const;
// Frame refresh rate implied by the timing constants.
static constexpr float refreshHz() {
return (float)PIXEL_CLOCK_HZ / ((float)CLOCKS_PER_ROW * LINES_PER_FRAME);
}
private:
bool startI2S();
void stopI2S();
void fillChunk(uint16_t chunk);
static void isr(void* arg);
EL512Pins _pins;
uint8_t* _fb = nullptr; // drawing buffer
uint8_t* _front = nullptr; // buffer being displayed
uint8_t* _ring = nullptr; // RING_BYTES, DMA-capable
lldesc_s* _desc = nullptr; // CHUNK_COUNT descriptors, circular
intr_handle_data_t* _intr = nullptr;
uint16_t _chunkLine[CHUNK_COUNT] = {}; // first line currently in each chunk
volatile bool _swapPending = false;
bool _running = false;
bool _enabled = false;
bool _lowPower = false;
};
+10
View File
@@ -0,0 +1,10 @@
{
"name": "EL512Core",
"version": "0.2.0",
"description": "Planar EL512.256-H refresh driver core (ESP-IDF API). Shared by lib/EL512 and the ESPHome component in this folder.",
"frameworks": ["arduino", "espidf"],
"platforms": "espressif32",
"build": {
"srcFilter": ["-<*>", "+<el512_panel.cpp>", "+<still_alive.cpp>"]
}
}
+252
View File
@@ -0,0 +1,252 @@
#include "still_alive.h"
#include <string.h>
#include "el512_font.h"
#include "el512_panel.h"
#include "still_alive_data.h"
namespace el512sa {
namespace {
uint8_t fit(int16_t px, int16_t cell, uint8_t cap) {
const int n = (px - 2 * TextBox::PAD) / cell;
return n > cap ? cap : (uint8_t) n;
}
bool due(uint32_t now, uint32_t at) { return (int32_t) (now - at) >= 0; }
void drawChar(EL512Panel &panel, int16_t x, int16_t y, char c) {
if (c < EL512_FONT_FIRST || c > EL512_FONT_LAST)
c = '?';
const uint8_t *glyph = &EL512_FONT[(c - EL512_FONT_FIRST) * 5];
for (int16_t col = 0; col < 5; col++) {
const uint8_t bits = glyph[col];
for (int16_t row = 0; row < 8; row++) {
if (bits & (1 << row))
panel.setPixel(x + col, y + row, true);
}
}
}
} // namespace
// --- TextBox -----------------------------------------------------------------
TextBox::TextBox(int16_t x, int16_t y, int16_t w, int16_t h)
: x_(x), y_(y), w_(w), h_(h), cols_(fit(w, CHAR_W, MAX_COLS)), rows_(fit(h, CHAR_H, MAX_ROWS)) {
clear();
}
void TextBox::clear() {
memset(text_, 0, sizeof(text_));
row_ = 0;
col_ = 0;
}
void TextBox::put(char c) {
if (c == '\n') {
newLine();
return;
}
if (col_ >= cols_)
newLine();
text_[row_][col_++] = c;
}
void TextBox::print(const char *s) {
while (*s)
put(*s++);
}
void TextBox::newLine() {
col_ = 0;
if (row_ + 1 < rows_) {
row_++;
return;
}
memmove(text_[0], text_[1], (size_t) (rows_ - 1) * sizeof(text_[0]));
memset(text_[rows_ - 1], 0, sizeof(text_[0]));
}
void TextBox::draw(EL512Panel &panel, bool cursor, bool frame) const {
if (frame) {
// Dashed outline, 4 px on / 2 px off.
for (int16_t i = 0; i < w_; i++) {
if (i % 6 < 4) {
panel.setPixel(x_ + i, y_, true);
panel.setPixel(x_ + i, y_ + h_ - 1, true);
}
}
for (int16_t i = 0; i < h_; i++) {
if (i % 6 < 4) {
panel.setPixel(x_, y_ + i, true);
panel.setPixel(x_ + w_ - 1, y_ + i, true);
}
}
}
for (uint8_t r = 0; r < rows_; r++) {
for (uint8_t c = 0; c < cols_ && text_[r][c]; c++) {
if (text_[r][c] != ' ')
drawChar(panel, x_ + PAD + c * CHAR_W, y_ + PAD + r * CHAR_H, text_[r][c]);
}
}
if (cursor) {
for (int16_t dy = 6; dy < 8; dy++)
for (int16_t dx = 0; dx < 5; dx++)
panel.setPixel(x_ + PAD + col_ * CHAR_W + dx, y_ + PAD + row_ * CHAR_H + dy, true);
}
}
// --- StillAlivePlayer ----------------------------------------------------------
// 41 x 31 lyrics console | 41 x 9 credits over a 41 x 20 picture.
StillAlivePlayer::StillAlivePlayer()
: lyrics_(0, 0, 254, 256), credits_(256, 0, 256, 86), art_(256, 88, 256, 168) {}
uint32_t StillAlivePlayer::songStartMs() { return START_DELAY_MS + SONG_START_MS; }
void StillAlivePlayer::start(uint32_t nowMs) {
lyrics_.clear();
credits_.clear();
showArt(ART_NONE);
showStart_ = nowMs + START_DELAY_MS;
step_ = 0;
charIdx_ = 0;
stepStarted_ = false;
done_ = false;
doneAt_ = 0;
creditIdx_ = 0;
creditChar_ = 0;
creditLineOpen_ = false;
creditsDone_ = false;
creditLineAt_ = CREDITS_START_MS;
// Credits are typed on the bottom row and scroll up, as in the game.
for (uint8_t r = 1; r < credits_.rows(); r++)
credits_.put('\n');
}
bool StillAlivePlayer::finished(uint32_t nowMs) const { return done_ && due(nowMs, doneAt_ + HOLD_MS); }
void StillAlivePlayer::showArt(int8_t id) {
art_.clear();
if (id <= ART_NONE || (size_t) id >= ART_COUNT)
return;
const Art &art = ARTS[id];
size_t width = 0;
for (uint8_t i = 0; i < art.count; i++) {
const size_t len = strlen(art.lines[i]);
if (len > width)
width = len;
}
const uint8_t top = art.count < art_.rows() ? (art_.rows() - art.count) / 2 : 0;
const uint8_t left = width < art_.cols() ? (art_.cols() - width) / 2 : 0;
for (uint8_t r = 0; r < top; r++)
art_.put('\n');
for (uint8_t i = 0; i < art.count && top + i < art_.rows(); i++) {
if (i > 0)
art_.put('\n');
for (uint8_t c = 0; c < left; c++)
art_.put(' ');
// Truncate instead of wrapping so a wide picture keeps its shape.
for (const char *p = art.lines[i]; *p && left + (p - art.lines[i]) < art_.cols(); p++)
art_.put(*p);
}
}
bool StillAlivePlayer::update(uint32_t nowMs) {
bool changed = false;
if (due(nowMs, showStart_)) {
const uint32_t t = nowMs - showStart_;
changed |= updateLyrics(t, nowMs);
changed |= updateCredits(t);
}
const bool blink = (nowMs / CURSOR_BLINK_MS) & 1;
const bool creditsBlink = blink && !creditsDone_ && due(nowMs, showStart_ + CREDITS_START_MS);
changed |= blink != cursor_ || creditsBlink != creditsCursor_;
cursor_ = blink;
creditsCursor_ = creditsBlink;
return changed;
}
// Type whatever the clock says should be on screen by now.
bool StillAlivePlayer::updateLyrics(uint32_t t, uint32_t nowMs) {
bool changed = false;
while (step_ < LYRIC_COUNT && t >= LYRICS[step_].startMs) {
const LyricStep &s = LYRICS[step_];
if (!stepStarted_) {
stepStarted_ = true;
if (s.flags & STEP_CLEAR)
lyrics_.clear();
if (s.art != ART_KEEP)
showArt(s.art);
changed = true;
}
const size_t len = strlen(s.text);
const uint32_t elapsed = t - s.startMs;
const size_t want = (s.durMs == 0 || elapsed >= s.durMs) ? len : len * elapsed / s.durMs;
while (charIdx_ < want) {
lyrics_.put(s.text[charIdx_++]);
changed = true;
}
if (charIdx_ < len)
break; // still typing this step
if (s.flags & STEP_NEWLINE)
lyrics_.put('\n');
step_++;
charIdx_ = 0;
stepStarted_ = false;
changed = true;
if (step_ == LYRIC_COUNT) {
done_ = true;
doneAt_ = nowMs;
}
}
return changed;
}
// Each credits line takes CREDIT_CHAR_US per character (an empty line counts as
// one), typed evenly; then the next line starts on a fresh bottom row.
bool StillAlivePlayer::updateCredits(uint32_t t) {
if (creditsDone_ || t < CREDITS_START_MS)
return false;
bool changed = false;
while (true) {
const char *line = CREDITS[creditIdx_];
const size_t len = strlen(line);
if (!creditLineOpen_) {
creditLineOpen_ = true;
credits_.put('\n');
changed = true;
}
const uint32_t dur = (uint32_t) ((uint64_t) CREDIT_CHAR_US * (len ? len : 1) / 1000);
const uint32_t elapsed = t - creditLineAt_;
const size_t want = elapsed >= dur ? len : len * elapsed / dur;
while (creditChar_ < want) {
credits_.put(line[creditChar_++]);
changed = true;
}
if (elapsed < dur)
break;
creditLineAt_ += dur;
creditChar_ = 0;
creditLineOpen_ = false;
if (++creditIdx_ == CREDIT_COUNT) {
creditIdx_ = 0;
if (!CREDITS_LOOP) {
creditsDone_ = true;
break;
}
}
}
return changed;
}
void StillAlivePlayer::draw(EL512Panel &panel) const {
lyrics_.draw(panel, cursor_);
credits_.draw(panel, creditsCursor_);
art_.draw(panel, false, false); // no frame around the picture, as in the game
}
} // namespace el512sa
+110
View File
@@ -0,0 +1,110 @@
// Portal-style end-credits show for the EL512.256-H: timed song text typed into
// a console on the left, credits typed top right, ASCII art bottom right.
//
// Framework-agnostic: draws straight into an EL512Panel with the built-in 5x7
// font, so the PlatformIO demo (src/still_alive) and the ESPHome component use
// the same code. The content is in still_alive_data.h, generated by
// tools/import_still_alive.py.
#pragma once
#include <stddef.h>
#include <stdint.h>
class EL512Panel;
namespace el512sa {
enum : uint8_t {
STEP_NEWLINE = 1 << 0, // start a new line after the text is typed
STEP_CLEAR = 1 << 1, // clear the console before typing
};
// One timed piece of console text. Steps are sorted by startMs.
struct LyricStep {
uint32_t startMs; // from the start of the show
uint16_t durMs; // the text is typed out evenly over this time
uint8_t flags;
int8_t art; // picture to show (index into ARTS), or ART_KEEP
const char *text;
};
struct Art {
const char *const *lines;
uint8_t count;
};
static constexpr int8_t ART_KEEP = -1; // leave the current picture
static constexpr int8_t ART_NONE = 0; // ARTS[0] is always the empty picture
// Fixed-size character console in the 5x7 font (6x8 cells), optionally inside a
// dashed frame. Text wraps at the right edge and scrolls up past the bottom row.
class TextBox {
public:
static constexpr uint8_t MAX_COLS = 48;
static constexpr uint8_t MAX_ROWS = 32;
static constexpr int16_t PAD = 4;
static constexpr int16_t CHAR_W = 6;
static constexpr int16_t CHAR_H = 8;
TextBox(int16_t x, int16_t y, int16_t w, int16_t h);
uint8_t cols() const { return cols_; }
uint8_t rows() const { return rows_; }
void clear();
void put(char c);
void print(const char *s);
void draw(EL512Panel &panel, bool cursor, bool frame = true) const;
private:
void newLine();
int16_t x_, y_, w_, h_;
uint8_t cols_, rows_;
uint8_t row_ = 0, col_ = 0;
char text_[MAX_ROWS][MAX_COLS + 1];
};
class StillAlivePlayer {
public:
static constexpr uint32_t START_DELAY_MS = 1000; // after start()
static constexpr uint32_t CURSOR_BLINK_MS = 300;
static constexpr uint32_t HOLD_MS = 10000; // after the last step, before finished()
StillAlivePlayer();
// Restart the show; it begins START_DELAY_MS after nowMs.
void start(uint32_t nowMs);
// Advance to nowMs; true if the screen changed (text, picture or cursor).
bool update(uint32_t nowMs);
// Draw the whole show; the caller clears the panel first and shows it after.
void draw(EL512Panel &panel) const;
// True once the last step has been typed and HOLD_MS has passed.
bool finished(uint32_t nowMs) const;
// When to start the music, relative to start().
static uint32_t songStartMs();
private:
bool updateLyrics(uint32_t t, uint32_t nowMs);
bool updateCredits(uint32_t t);
void showArt(int8_t id);
TextBox lyrics_;
TextBox credits_;
TextBox art_;
uint32_t showStart_ = 0;
size_t step_ = 0;
size_t charIdx_ = 0;
bool stepStarted_ = false;
bool done_ = false;
uint32_t doneAt_ = 0;
size_t creditIdx_ = 0;
size_t creditChar_ = 0;
bool creditLineOpen_ = false;
bool creditsDone_ = false;
uint32_t creditLineAt_ = 0;
bool cursor_ = false;
bool creditsCursor_ = false;
};
} // namespace el512sa
+766
View File
@@ -0,0 +1,766 @@
// 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 "still_alive.h"
namespace el512sa {
static const char* const ART_APERTURE_LINES[] = {
" .,-:;//;:=,",
" . :H@@@MM@M#H/.,+%;,",
" ,/X+ +M@@M@MM%=,-%HMMM@X/,",
" -+@MM; $M@@MH+-,;XMMMM@MMMM@+-",
" ;@M@@M- XM@X;. -+XXXXXHHH@M@M#@/.",
" ,%MM@@MH ,@%= .---=-=:=,.",
" =@#@@@MX ., -%HX$$%%%+;",
" =-./@M@M$ .;@MMMM@MM:",
" X@/ -$MM/ .+MM@@@M$",
",@M@H: :@: . =X#@@@@-",
",@@@MMX, . /H- ;@M@M=",
".H@@@@M@+, %MM+..%#$.",
" /MMMM@MMH/. XM@MH; =;",
" /%+%$XHH@$= , .H@@@@MX,",
" .=--------. -%H.,@@@@@MX,",
" .%MM@@@HHHXX$$$%+- .:$MMX =M@@MM%.",
" =XMMM@MM@MM#H;,-+HMM@M+ /MMMX=",
" =%@M@M#@$-.=$@MM@@@M; %M%=",
" ,:+$+-,/H#MMMMMMM@= =,",
" =++%%%%+/:-.",
};
static const char* const ART_CAKE_LINES[] = {
" ,:/+/-",
" /M/ .,-=;//;-",
" .:/= ;MH/, ,=/+%$XH@MM#@:",
" -$##@+$###@H@MMM#######H:. -/H#",
" .,H@H@ X######@ -H#####@+- -+H###@X",
" .,@##H; +XM##M/, =%@###@X;-",
"X%- :M##########$. .:%M###@%:",
"M##H, +H@@@$/-. ,;$M###@%, -",
"M####M=,,---,.-%%H####M$: ,+@##",
"@##################@/. :%H##@$-",
"M###############H, ;HM##M$=",
"#################. .=$M##M$=",
"################H..;XM##M$= .:+",
"M###################@%= =+@MH%",
"@################M/. =+H#X%=",
"=+M##############M, -/X#X+;.",
" .;XM##########H= ,/X#H+:,",
" .=+HM######M+/+HM@+=.",
" ,:/%XM####H/.",
" ,.:=-.",
};
static const char* const ART_EXPLOSION_LINES[] = {
" .+",
" /M;",
" H#@: ;,",
" -###H- -@/",
" %####$. -; .%#X",
" M#####+;#H :M#M.",
".. .+/;%#########X###-",
" -/%H%+;-, +##############/",
" .:$M###MH$%+############X ,--=;-",
" -/H#####################H+=.",
" .+#################X.",
" =%M####################H;.",
" /@###############+;;/%%;,",
" -%###################$.",
" ;H######################M=",
" ,%#####MH$%;+#####M###-/@####%",
" :$H%+;=- -####X.,H# -+M##@-",
" . ,###; ; =$##+",
" .#H, :XH,",
" + .;-",
};
static const char* const ART_RADIO_LINES[] = {
" ;=",
" /=",
" ;=",
" /=",
" ;=",
" /=",
" ;=",
" /=",
" ,--==-:$;",
" ,/$@#######@X+-",
" ./@###############X=",
" /M#####X+/;;;;+H#####$.",
" %####M/;+H@XX@@%;;@####@,",
" +####H=+##$,--,=M#X-%####@.",
" -####X,X@HHXH##MXHXXH-+####$",
" X###@.X/$M$:####$=@X/X,X####-",
".####:+$:##@:####$:##H/X=####%",
"-%%$%,+==%$+-$+:$;-$$%-+,/$%%+",
"-/+%%X$XX$$$$$$$%$$$%$X$X$%+/-",
};
static const char* const ART_ATOM_LINES[] = {
" =/;;/-",
" +: //",
" /; /;",
" -X H.",
".//;;;:;;-, X= :+ .-;:=;:;%;.",
"M- ,=;;;#:, ,:#;;:=, ,@",
":% :%.=/++++/=.$= %=",
" ,%; %/:+/;,,/++:+/ ;+.",
" ,+/. ,;@+, ,%H;, ,/+,",
" ;+;;/= @. .H##X -X :///+;",
" ;+=;;;.@, .XM@$. =X.//;=%/.",
" ,;: :@%= =$H: .+%-",
" ,%= %;-///==///-// =%,",
";+ :%-;;;:;;;;-X- +:",
"@- .-;;;;M- =M/;;;-. -X",
" :;;::;;-. %- :+ ,-;;-;:==",
" ,X H.",
" ;/ %=",
" // +;",
" ,////,",
};
static const char* const ART_BIOHAZARD_LINES[] = {
" =+$HM####@H%;,",
" /H###############M$,",
" ,@################+",
" .H##############+",
" X############/",
" $##########/",
" %########/",
" /X/;;+X/",
"",
" -XHHX-",
" ,######,",
"#############X .M####M. X#############",
"##############- -//- -##############",
"X##############%, ,+##############X",
"-##############X X##############-",
" %############% %############%",
" %##########; ;##########%",
" ;#######M= =M#######;",
" .+M###@, ,@###M+.",
" :XH. .HX:",
};
static const char* const ART_BLACKMESA_LINES[] = {
" .-;+$XHHHHHHX$+;-.",
" ,;X@@X%/;=----=:/%X@@X/,",
" =$@@%=. .=+H@X:",
" -XMX: =XMX=",
" /@@: =H@+",
" %@X, .$@$",
" +@X. $@%",
"-@@, .@@=",
"%@% +@$",
"H@: :@H",
"H@: :HHHHHHHHHHHHHHHHHHX, =@H",
"%@% ;@M@@@@@@@@@@@@@@@@@H- +@$",
"=@@, :@@@@@@@@@@@@@@@@@@@@@= .@@:",
" +@X :@@@@@@@@@@@@@@@M@@@@@@:%@%",
" $@$, ;@@@@@@@@@@@@@@@@@M@@@@@@$.",
" +@@HHHHHHH@@@@@@@@@@@@@@@@@@@@@@@+",
" =X@@@@@@@@@@@@@@@@@@@@@@@@@@@@X=",
" :$@@@@@@@@@@@@@@@@@@@M@@@@$:",
" ,;$@@@@@@@@@@@@@@@@@@X/-",
" .-;+$XXHHHHHX$+;-.",
};
static const char* const ART_BROKENHEART_LINES[] = {
" .,---.",
" ,/XM#MMMX;,",
" -%##########M%,",
" -@######% $###@=",
" .,--, -H#######$ $###M:",
" ,;$M###MMX; .;##########$;HM###X=",
" ,/@##########H= ;################+",
"-+#############M/, %##############+",
"%M###############= /##############:",
"H################ .M#############;.",
"@###############M ,@###########M:.",
"X################, -$=X#######@:",
"/@##################%- +######$-",
".;##################X .X#####+,",
" .;H################/ -X####+.",
" ,;X##############, .MM/",
" ,:+$H@M#######M#$- .$$=",
" .,-=;+$@###X: ;/=.",
" .,/X$; .::,",
" ., ..",
};
static const char* const ART_CHECK_LINES[] = {
" :X-",
" :X###",
" ;@####@",
" ;M######X",
" -@########$",
" .$##########@",
" =M############-",
" +##############$",
" .H############$=.",
" ,/: ,M##########M;.",
" -+@###; =##########M;",
" =%M#######; :#########M/",
"-$M###########; :#########/",
" ,;X###########; =########$.",
" ;H#########+#######M=",
" ,+##############+",
" /M#########@-",
" ;M######%",
" +####:",
" ,$M-",
};
static const char* const ART_COMPANIONCUBE_LINES[] = {
"+@##########M/ :@#########@/",
"##############$;H#######@;+#############",
"###############M########################",
"##############X,-/++/+%+/,%#############",
"############M$: -X############",
"##########H;. ,--. =X##########",
":X######M; -$H@M##MH%: :H#######@",
" =%#M+=, ,+@#######M###H: -=/M#%",
" %M##@+ .X##$, ./+- ./###; +M##%",
" %####M. /###= @##M. X###%",
" %####M. ;M##H:. =$###X. $###%",
" %####@. /####M$-./@#####: %###%",
" %H#M/, /H###########@: ./M#%",
" ;$H##@@H: .;$HM#MMMH$;, ./H@M##M$=",
"X#########%. ..,,. .;@#########",
"###########H+:. ./@###########",
"##############/ ./%%%%+/.-M#############",
"##############H$@#######@@##############",
"##############X%########M$M#############",
"+M##########H: .$##########X=",
};
static const char* const ART_FIRE_LINES[] = {
" -$-",
" .H##H,",
" +######+",
" .+#########H.",
" -$############@.",
" =H###############@ -X:",
" .$##################: @#@-",
" ,; .M###################; H###;",
" ;@#: @###################@ ,#####:",
" -M###. M#################@. ;######H",
" M####- +###############$ =@#######X",
" H####$ -M###########+ :#########M,",
" /####X- =########% :M########@/.",
" ,;%H@X; .$###X :##MM@%+;:-",
" ..",
" -/;:-,. ,,-==+M########H",
" -##################@HX%%+%%$%%%+:,,",
" .-/H%%%+%%$H@###############M@+=:/+:",
"/XHX%:#####MH%= ,---:;;;;/%%XHM,:###$",
};
static const char* const ART_GLADOS_LINES[] = {
" #+ @ # # M#@",
" . .X X.%##@;# # +@#######X. @#%",
" ,==. ,######M+ -#####%M####M- #",
" :H##M%:=##+ .M##M,;#####/+#######% ,M#",
" .M########= =@#@.=#####M=M#######= X#",
" :@@MMM##M. -##M.,#######M#######. = M",
" @##..###:. .H####. @@ X,",
" ############: ###,/####; /##= @#. M",
" ,M## ;##,@#M;/M#M @# X#% X#",
".%= ######M## ##.M#: ./#M ,M #M ,#$",
"##/ $## #+;#: #### ;#/ M M- @# :",
"#+ #M@MM###M-;M #:$#-##$H# .#X @ + $#. #",
" ######/.: #%=# M#:MM./#.-# @#: H#",
"+,.= @###: /@ %#,@ ##@X #,-#@.##% .@#",
"#####+;/##/ @## @#,+ /#M . X,",
" ;###M#@ M###H .#M- ,##M ;@@; ###\"",
" .M#M##H ;####X ,@#######M/ -M###$ -H",
" .M###% X####H .@@MM@; ;@#M@",
" H#M /@####/ ,++. / ==-,",
" ,=/:, .+X@MMH@#H #####$=",
};
static const char* const ART_LIGHTBULB_LINES[] = {
" ,=;%$%%$X%%%%;/%%%%;=,",
" ,/$$+:- -:+$$/,",
" :X$= =$X:",
" ;M%. .%M;",
"+#/ /#+",
"## M#",
"H#, =;+/;, ,#X",
".HM- :@X+%H: .%M%- .M#. -M@.",
" /#%. @#- ,H@--MH, .;@$- .%#+",
" .$M; .+@X;, MM#@:/$X;. ;M$,",
" =@H, ,:+%H#M%;- ,H@=",
" .$#; -#H =#$",
" %#; #M ;#%",
" H#- ## -#H",
" ;#+ ## +#;",
" ;H+;;;;;;HH;;;;;;+H/",
" =H#@HHHHHHHHHH@#H=",
" =@#H%%%%%%%$HH@#@=",
" =@#X%%%%%%%$M###@=",
" =+%XHHX%+=",
};
static const char* const ART_MECHARM_LINES[] = {
" X MM X",
" X MM X",
" X MM X",
" X MM X",
" + HX +",
" ,=$$XX%/-",
" =X#########@%-",
" ;##############=",
"-###############M,",
";##@@@######M@###=",
".+:;+:=H##$=:/:;H.",
"- +###- ## :###,,;",
"+@:/%;-H##H==/::H;",
" /#@/-=+$$%::+H#$",
" $#%-, ,.:##-",
"-@/ =X%.",
"%H= -$;",
" =HH, .%M;",
" /MM/ :@M/.",
" .:XX, -$H:.",
};
static const char* const ART_PERSONALITYSPHERE_LINES[] = {
" .-+$H###MM@MMMMM##@$+-,. ....",
"-@$+%$+%HX+--.. . . .,:X$/+/++$#:",
"-#MXH$= $HXH#:",
" .--,:#+ ,+$HMX =@@X%, . .X#:,,,",
" =#@$H :####H =####;,M%$#X",
" X###$ $####X =####H %###X",
" ;###X /###@$: ,+HM##H.+###;",
" :###;,X##%=;%H@H$;-;M#@-;###/",
" ,M##;.@##;-H#######M=.M##-:###-",
" ;##M ;##X @###H-=@###.;##X H##;",
" ;##M./##X.@###H:/M###-=##X X##;",
" -###;,M##:,@########+-H##; @##-",
" %##M==@##%==%HMH%::/M##+.X##+",
" %###/./###X+: -+$M##M=,X##+",
" X###X X####H +#####% @##H",
" :###H %####H +#####; X##;",
" /#$. -HM##H /###@+. +#$. .",
"/HX%$X: .,-, .-,. =XX$H@-",
"/#H+/+%+/+;=. .=/%;;/;;+#+",
" .. .,-:XM#MM@@@@@@H@@M#@+=,. ,,",
};
static const Art ARTS[] = {
{nullptr, 0},
{ART_APERTURE_LINES, 20}, // 1
{ART_CAKE_LINES, 20}, // 2
{ART_EXPLOSION_LINES, 20}, // 3
{ART_RADIO_LINES, 19}, // 4
{ART_ATOM_LINES, 20}, // 5
{ART_BIOHAZARD_LINES, 20}, // 6
{ART_BLACKMESA_LINES, 20}, // 7
{ART_BROKENHEART_LINES, 20}, // 8
{ART_CHECK_LINES, 20}, // 9
{ART_COMPANIONCUBE_LINES, 20}, // 10
{ART_FIRE_LINES, 19}, // 11
{ART_GLADOS_LINES, 20}, // 12
{ART_LIGHTBULB_LINES, 20}, // 13
{ART_MECHARM_LINES, 20}, // 14
{ART_PERSONALITYSPHERE_LINES, 20}, // 15
};
static constexpr size_t ART_COUNT = sizeof(ARTS) / sizeof(ARTS[0]);
static const LyricStep LYRICS[] = {
{0, 0, STEP_CLEAR, ART_KEEP, ""},
{0, 2000, STEP_NEWLINE, ART_KEEP, "Forms FORM-29827281-12:"},
{2000, 2000, STEP_NEWLINE, ART_KEEP, "Test Assessment Report"},
{4010, 0, STEP_NEWLINE, ART_KEEP, ""},
{4020, 0, STEP_NEWLINE, ART_KEEP, ""},
{6870, 2050, STEP_NEWLINE, ART_KEEP, "This was a triumph."},
{10820, 2100, STEP_NEWLINE, ART_KEEP, "I'm making a note here:"},
{12930, 1800, STEP_NEWLINE, ART_KEEP, "HUGE SUCCESS."},
{15730, 2360, STEP_NEWLINE, ART_KEEP, "It's hard to overstate"},
{18130, 2710, STEP_NEWLINE, ART_KEEP, "my satisfaction."},
{22970, 1760, STEP_NEWLINE, 1, "Aperture Science"},
{26830, 1570, STEP_NEWLINE, ART_KEEP, "We do what we must"},
{28600, 750, 0, ART_KEEP, "because "},
{29550, 710, STEP_NEWLINE, ART_KEEP, "we can."},
{31930, 1550, 0, ART_KEEP, "For the good "},
{33480, 1530, STEP_NEWLINE, ART_KEEP, "of all of us."},
{35289, 1741, STEP_NEWLINE, 6, "Except the ones who are dead."},
{37030, 400, STEP_NEWLINE, ART_KEEP, ""},
{37430, 1858, STEP_NEWLINE, 1, "But there's no sense crying"},
{39298, 1782, STEP_NEWLINE, ART_KEEP, "over every mistake."},
{41280, 1985, STEP_NEWLINE, ART_KEEP, "You just keep on trying"},
{43275, 1366, 0, ART_KEEP, "till you run out of "},
{44641, 400, STEP_NEWLINE, ART_KEEP, "cake."},
{45227, 1970, STEP_NEWLINE, 5, "And the Science gets done."},
{47197, 1970, STEP_NEWLINE, ART_KEEP, "And you make a neat gun."},
{49187, 1520, STEP_NEWLINE, 1, "For the people who are"},
{50727, 1500, STEP_NEWLINE, ART_KEEP, "still alive."},
{53827, 0, STEP_CLEAR, ART_KEEP, ""},
{53827, 550, STEP_NEWLINE, ART_KEEP, "Forms FORM-55551-5:"},
{54377, 1100, STEP_NEWLINE, ART_KEEP, "Personnel File Addendum:"},
{55477, 0, STEP_NEWLINE, ART_KEEP, ""},
{58487, 0, STEP_NEWLINE, ART_KEEP, ""},
{58547, 1917, STEP_NEWLINE, ART_KEEP, "I'm not even angry."},
{62832, 697, 0, ART_KEEP, "I'm being "},
{63529, 650, 0, ART_KEEP, "so "},
{64179, 906, 0, ART_KEEP, "sincere "},
{65085, 1115, STEP_NEWLINE, ART_KEEP, "right now."},
{67825, 650, 0, ART_KEEP, "Even "},
{68475, 1347, 0, ART_KEEP, "though you "},
{69822, 488, 0, 8, "broke "},
{70310, 998, STEP_NEWLINE, ART_KEEP, "my heart."},
{71308, 325, 0, ART_KEEP, "And "},
{71633, 488, 0, ART_KEEP, "killed "},
{72121, 557, STEP_NEWLINE, ART_KEEP, "me."},
{74678, 1649, STEP_NEWLINE, 3, "And tore me to pieces."},
{78649, 1440, 0, ART_KEEP, "And threw every piece "},
{80091, 580, 0, ART_KEEP, "into "},
{81066, 882, STEP_NEWLINE, 11, "a fire."},
{83597, 1400, 0, ART_KEEP, "As they burned "},
{84997, 1509, STEP_NEWLINE, ART_KEEP, "it hurt because"},
{86831, 1625, STEP_NEWLINE, 9, "I was so happy for you!"},
{88897, 2067, STEP_NEWLINE, ART_KEEP, "Now these points of data"},
{90964, 1950, STEP_NEWLINE, ART_KEEP, "make a beautiful line."},
{92914, 2020, STEP_NEWLINE, ART_KEEP, "And we're out of beta."},
{94934, 1950, STEP_NEWLINE, ART_KEEP, "We're releasing on time."},
{96884, 2113, STEP_NEWLINE, 3, "So I'm GLaD. I got burned."},
{98997, 2020, STEP_NEWLINE, 5, "Think of all the things we learned"},
{101017, 1556, STEP_NEWLINE, 1, "for the people who are"},
{102573, 395, 0, ART_KEEP, "still "},
{102968, 1440, STEP_NEWLINE, ART_KEEP, "alive."},
{106008, 0, STEP_CLEAR, ART_KEEP, ""},
{106008, 450, STEP_NEWLINE, ART_KEEP, "Forms FORM-55551-6:"},
{106459, 1310, STEP_NEWLINE, ART_KEEP, "Personnel File Addendum Addendum:"},
{107768, 0, STEP_NEWLINE, ART_KEEP, ""},
{108428, 2200, STEP_NEWLINE, ART_KEEP, "One last thing:"},
{110628, 0, STEP_NEWLINE, ART_KEEP, ""},
{110678, 1324, 0, ART_KEEP, "Go ahead and leave "},
{112002, 627, STEP_NEWLINE, ART_KEEP, "me."},
{114417, 534, 0, ART_KEEP, "I think "},
{114951, 1184, 0, ART_KEEP, "I prefer "},
{116135, 1022, 0, ART_KEEP, "to stay "},
{117157, 232, 0, ART_KEEP, "in"},
{117389, 882, STEP_NEWLINE, ART_KEEP, "side."},
{119701, 511, 0, ART_KEEP, "May"},
{120212, 2972, STEP_NEWLINE, ART_KEEP, "be you'll find someone else"},
{123184, 1254, STEP_NEWLINE, ART_KEEP, "to help you."},
{126690, 511, 0, ART_KEEP, "Maybe "},
{127201, 325, 0, 7, "Black "},
{127526, 998, 0, ART_KEEP, "Mesa"},
{128524, 2183, STEP_NEWLINE, ART_KEEP, "..."},
{130707, 1440, 0, ART_KEEP, "THAT WAS A JOKE. "},
{133168, 279, 0, ART_KEEP, "FAT "},
{133447, 859, STEP_NEWLINE, ART_KEEP, "CHANCE."},
{135699, 1486, 0, ART_KEEP, "Anyway"},
{137185, 1765, STEP_NEWLINE, 2, ", this cake is great."},
{138950, 1904, STEP_NEWLINE, ART_KEEP, "It's so delicious and moist."},
{141272, 1927, STEP_NEWLINE, 12, "Look at me still talking"},
{143199, 2090, STEP_NEWLINE, 6, "when there's Science to do."},
{145289, 1695, STEP_NEWLINE, 1, "When I look out there,"},
{146984, 2183, STEP_NEWLINE, ART_KEEP, "it makes me GLaD I'm not you."},
{149167, 2043, STEP_NEWLINE, 5, "I've experiments to run."},
{151210, 2020, STEP_NEWLINE, 3, "There is research to be done."},
{153230, 1625, STEP_NEWLINE, 1, "On the people who are"},
{154855, 348, 0, ART_KEEP, "still "},
{155203, 1440, STEP_NEWLINE, ART_KEEP, "alive."},
{156843, 0, STEP_CLEAR, ART_KEEP, ""},
{156953, 0, STEP_NEWLINE, ART_KEEP, ""},
{156963, 0, STEP_NEWLINE, ART_KEEP, ""},
{156973, 0, STEP_NEWLINE, ART_KEEP, ""},
{156983, 300, 0, ART_NONE, "PS: "},
{157283, 1625, STEP_NEWLINE, ART_KEEP, "And believe me I am"},
{158908, 1138, STEP_NEWLINE, ART_KEEP, "still alive."},
{160846, 198, 0, ART_KEEP, "PPS: "},
{161044, 1904, STEP_NEWLINE, ART_KEEP, "I'm doing Science and I'm"},
{162948, 1091, STEP_NEWLINE, ART_KEEP, "still alive."},
{164839, 175, 0, ART_KEEP, "PPPS: "},
{165014, 1904, STEP_NEWLINE, ART_KEEP, "I feel FANTASTIC and I'm"},
{166918, 1091, STEP_NEWLINE, ART_KEEP, "still alive."},
{168009, 0, STEP_NEWLINE, ART_KEEP, ""},
{168109, 1154, STEP_NEWLINE, ART_KEEP, "FINAL THOUGHT: "},
{169263, 1579, STEP_NEWLINE, ART_KEEP, "While you're dying I'll be"},
{170842, 1254, STEP_NEWLINE, ART_KEEP, "still alive."},
{172096, 0, STEP_NEWLINE, ART_KEEP, ""},
{172196, 829, STEP_NEWLINE, ART_KEEP, "FINAL THOUGHT PS: "},
{173025, 1834, STEP_NEWLINE, ART_KEEP, "And when you're dead I will be"},
{174859, 1161, STEP_NEWLINE, ART_KEEP, "still alive."},
{176020, 0, STEP_NEWLINE, ART_KEEP, ""},
{176479, 0, STEP_NEWLINE, ART_KEEP, ""},
{176879, 1184, STEP_NEWLINE, ART_KEEP, "STILL ALIVE"},
{179250, 0, STEP_CLEAR, ART_KEEP, ""},
{180250, 0, STEP_NEWLINE, ART_KEEP, ""},
{181250, 0, STEP_NEWLINE, ART_KEEP, ""},
{182250, 0, STEP_NEWLINE, ART_KEEP, ""},
{183250, 1500, STEP_NEWLINE, ART_KEEP, " ***"},
{184750, 1500, STEP_NEWLINE, ART_KEEP, " Portal? 10th anniversary"},
{186250, 1500, STEP_NEWLINE, ART_KEEP, " 'Still Alive' ending scene"},
{187750, 1500, STEP_NEWLINE, ART_KEEP, " remade with HTML, JS"},
{189250, 2500, STEP_NEWLINE, ART_KEEP, " by SD SkyKlouD"},
{191750, 0, STEP_NEWLINE, ART_KEEP, ""},
{192250, 600, STEP_NEWLINE, ART_KEEP, " ? 2007- Valve Corporation."},
{192850, 900, STEP_NEWLINE, ART_KEEP, " All Rights Reserved."},
{193750, 1500, STEP_NEWLINE, ART_KEEP, " Valve, Steam, Portal are trademarks"},
{195250, 1500, STEP_NEWLINE, ART_KEEP, " and/or registered trademarks of"},
{196750, 1500, STEP_NEWLINE, ART_KEEP, " Valve Corporation. All other trademarks"},
{198250, 1500, STEP_NEWLINE, ART_KEEP, " are property of their respective owners."},
};
static constexpr size_t LYRIC_COUNT = sizeof(LYRICS) / sizeof(LYRICS[0]);
static const char* const CREDITS[] = {
">LIST PERSONNEL",
"",
"",
"Gautam Babbar",
"Ted Backman",
"Kelly Bailey",
"Jeff Ballinger",
"Aaron Barber",
"Jeep Barnett",
"Jeremy Bennett",
"Dan Berger",
"Yahn Bernier",
"Ken Birdwell",
"Derrick Birum",
"Mike Blaszczak",
"Iestyn Bleasdale-Shepherd",
"Chris Bokitch",
"Steve Bond",
"Matt Boone",
"Antoine Bourdon",
"Jamaal Bradley",
"Jason Brashill",
"Charlie Brown",
"Charlie Burgin",
"Andrew Burke",
"Augusta Butlin",
"Julie Caldwell",
"Dario Casali",
"Chris Chin",
"Jess Cliffe",
"Phil Co",
"John Cook",
"Christen Coomer",
"Greg Coomer",
"Scott Dalton",
"Kerry Davis",
"Jason Deakins",
"Joe Demers",
"Ariel Diaz",
"Quintin Doroquez",
"Jim Dose",
"Chris Douglass",
"Laura Dubuk",
"Mike Dunkle",
"Mike Durand",
"Mike Dussault",
"Dhabih Eng",
"Katie Engel",
"Chet Faliszek",
"Adrian Finol",
"Bill Fletcher",
"Moby Francke",
"Stephane Gaudette",
"Kathy Gehrig",
"Vitaliy Genkin",
"Paul Graham",
"Chris Green",
"Chris Grinstead",
"John Guthrie",
"Aaron Halifax",
"Reagan Halifax",
"Leslie Hall",
"Jeff Hameluck",
"Joe Han",
"Don Holden",
"Jason Holtman",
"Gray Horsfield",
"Keith Huggins",
"Jim Hughes",
"Jon Huisingh",
"Brian Jacobson",
"Lars Jensvold",
"Erik Johnson",
"Jakob Jungels",
"Rich Kaethler",
"Steve Kalning",
"Aaron Kearly",
"Iikka Keranen",
"David Kircher",
"Eric Kirchmer",
"Scott Klintworth",
"Alden Kroll",
"Marc Laidlaw",
"Jeff Lane",
"Tim Larkin",
"Dan LeFree",
"Isabelle LeMay",
"Tom Leonard",
"Jeff Lind",
"Doug Lombardi",
"Bianca Loomis",
"Richard Lord",
"Realm Lovejoy",
"Randy Lundeen",
"Scott Lynch",
"Ido Magal",
"Nick Maggiore",
"John McCaskey",
"Patrick McClard",
"Steve McClure",
"Hamish McKenzie",
"Gary McTaggart",
"Jason Mitchell",
"Mike Morasky",
"John Morello II",
"Bryn Moslow",
"Arsenio Navarro",
"Gabe Newell",
"Milton Ngan",
"Jake Nicholson",
"Martin Otten",
"Nick Papineau",
"Karen Prell",
"Bay Raitt",
"Tristan Reidford",
"Alfred Reynolds",
"Matt Rhoten",
"Garret Rickey",
"Dave Riller",
"Elan Ruskin",
"Matthew Russell",
"Jason Ruymen",
"David Sawyer",
"Marc Scaparro",
"Wade Schin",
"Matthew Scott",
"Aaron Seeler",
"Jennifer Seeley",
"Taylor Sherman",
"Eric Smith",
"Jeff Sorensen",
"David Speyrer",
"Jay Stelly",
"Jeremy Stone",
"Eric Strand",
"Kim Swift",
"Kelly Thornton",
"Eric Twelker",
"Carl Uhlman",
"Doug Valente",
"Bill Van Buren",
"Gabe Van Engel",
"Alex Vlachos",
"Robin Walker",
"Joshua Weier",
"Andrea Wicklund",
"Greg Winkler",
"Erik Wolpaw",
"Doug Wood",
"Matt T. Wood",
"Danika Wright",
"Matt Wright",
"Shawn Zabecki",
"Torsten Zabka ",
"",
"",
"",
"",
"'Still Alive' by:",
"Jonathan Coulton",
"",
"",
"",
"Voices:",
"Ellen McLain - GlaDOS, Turrets",
"Mike Patton - THE ANGER SPHERE",
"",
"",
"",
"Voice Casting:",
"Shana LandsburgTeri Fiddleman",
"",
"",
"",
"",
"Voice Recording:",
"Pure Audio, Seattle, WA",
"",
"",
"",
"",
"Voice recording",
"scheduling and logistics:",
"Pat Cockburn, Pure Audio",
"",
"",
"",
"",
"Translations:",
"SDL",
"",
"",
"",
"",
"Crack Legal Team:",
"Liam Lavery",
"Karl Quackenbush",
"Kristen Boraas",
"Kevin Rosenfield",
"Alan Bruggeman",
"Dennis Tessier",
"",
"",
"",
"Thanks for the use of their face:",
"Alesia Glidewell - Chell",
"",
"",
"",
"Special thanks to everyone at:",
"Alienware",
"ATI",
"Dell",
"Falcon Northwest",
"Havok",
"SOFTIMAGE",
"and Don Kemmis, SLK Technologies",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"THANK YOU FOR PARTICIPATING",
"IN THIS",
"ENRICHMENT CENTER ACTIVITY!!",
"",
"",
"",
"",
"",
"",
"",
"",
};
static constexpr size_t CREDIT_COUNT = sizeof(CREDITS) / sizeof(CREDITS[0]);
static constexpr uint32_t SONG_START_MS = 6750;
static constexpr uint32_t CREDITS_START_MS = 9000;
static constexpr uint32_t CREDIT_CHAR_US = 68624;
static constexpr bool CREDITS_LOOP = false;
} // namespace el512sa