Cinematic Text
How script-triggered letterbox text is stored in Display state, rendered frame-rate-independently, and why it was invisible before the fix.
Several single-player missions display a title or subtitle in the lower letterbox at key story beats — a general's name appearing when they radio in, an objective summary before a scripted assault. The script action is DISPLAY_CINEMATIC_TEXT. The text system has three layers: the script engine that parses the action, the Display base class that holds state, and W3DDisplay that renders it each frame. In the previous D3D11 port state the render layer never consumed that state, so cinematic text was silently dropped on every mission that used it.
Script action: parsing and state write
ScriptActions::doDisplayCinematicText() in ScriptActions.cpp takes three parameters: a string ID, a font specification, and a duration in seconds. The string is resolved through the localization table. The font spec is a formatted string like "Arial - Size: 14" or "Times New Roman - Size: 18 [Bold]" that the action parses inline:
UnicodeString uStr = TheGameText->fetch(displayText); AsciiString aStr; aStr.translate(uStr); TheDisplay->setCinematicText(aStr); // ... parse fontType into fontName / size / bold ... Bool bold = fontType.endsWith("[Bold]"); GameFont *font = TheFontLibrary->getFont(fontName, TheGlobalLanguageData->adjustFontSize(size), bold); TheDisplay->setCinematicFont(font); Int frames = LOGICFRAMES_PER_SECOND * timeInSeconds; TheDisplay->setCinematicTextFrames(frames);
LOGICFRAMES_PER_SECOND is 30, so a 5-second call becomes a 150-frame countdown. The action writes into three members on Display: m_cinematicText, m_cinematicFont, and m_cinematicTextFrames. Nothing else runs until the render loop picks it up.
The render block
W3DDisplay::draw() in W3DDisplay.cpp guards on non-empty text and a positive frame counter, caches a single DisplayString, and rebuilds only when text, font, or display width changes:
if (m_cinematicText != AsciiString::TheEmptyString && m_cinematicTextFrames != 0) { static DisplayString* s_cinematicDS = nullptr; static AsciiString s_lastText; static GameFont* s_lastFont = nullptr; static Int s_lastDisplayWidth = -1; if (!s_cinematicDS) s_cinematicDS = TheDisplayStringManager->newDisplayString(); if (textChanged || fontChanged || widthChanged) { s_cinematicDS->setFont(m_cinematicFont); s_cinematicDS->setWordWrap(displayWidth - 20); s_cinematicDS->setWordWrapCentered(TRUE); UnicodeString uText; uText.translate(m_cinematicText); s_cinematicDS->setText(uText); } // ... draw at (xPos, yPos) ... }
The static DisplayString* is the fix for the original's per-frame newDisplayString leak. It persists across frames, so the engine allocates once per mission instead of once per render frame.
Position is centered horizontally at 90% of screen height — inside the letterbox bar if LetterBoxDisplay is active, on the bare screen otherwise. Color is white with a black drop shadow.
Logic-frame countdown
The counter decrements on logic-frame transitions, not render-frame:
if (m_cinematicTextFrames > 0 && TheGameLogic) { static UnsignedInt s_lastLogicFrame = ~0u; const UnsignedInt curLogicFrame = TheGameLogic->getFrame(); if (curLogicFrame != s_lastLogicFrame) { s_lastLogicFrame = curLogicFrame; --m_cinematicTextFrames; } }
The original engine throttled draw() internally to 30 Hz so a per-call decrement matched logic frames 1:1. On modern displays draw() can run at 144, 240, or higher refresh rates; a naive decrement would expire a 5-second text in about 1 second at 144 Hz. The s_lastLogicFrame gate ties expiry to TheGameLogic->getFrame() advances instead, so wall-clock duration is preserved regardless of render rate.
Why the text was silent
Before the fix, doDisplayCinematicText wrote the state correctly and Display stored it correctly, but W3DDisplay::draw() had no render block at all — the feature was simply not wired up in the D3D11 port. The original D3D8 path had the same render logic embedded in its draw sequence; it didn't survive the port. Adding the block above restored the feature without changes to the script engine or Display state. A mission-specific trigger was the first reliable reproduction case, since nothing asserts or logs when the text is dropped.
Quirks
Font spec parsing is fragile. The
doDisplayCinematicTextparser splits on the literal substring- Size:to find the size token. A font name that happens to contain that substring would parse incorrectly. All shipping mission scripts use plain font names, so no real missions trip it.endsWith("[Bold]")is case-sensitive. A lowercase[bold]or[BOLD]suffix is ignored and the font falls back to regular weight. The shipping data is consistent about capitalization; a custom mission that typos this will render non-bold even when authored for bold.The counter uses
!= 0, not> 0. Settingm_cinematicTextFramesnegative (which is possible if a call is stacked without> 0clamping) would keep the text visible indefinitely while the decrement keeps running further negative. The only writer is the script action, which always passes a positive value, so this is a theoretical edge case.m_cinematicTextFramesis not serialized. It lives onDisplay, which is not part of the save-game or replay snapshot. A save taken mid-display reloads withm_cinematicTextFrames = 0, and the text does not reappear even if the original script action already fired. Replays do not carry this state either — the script engine reruns the originalDISPLAY_CINEMATIC_TEXTaction on playback, which sets the state fresh.DisplayStringis never freed. The static pointer persists until process exit. This is intentional — the alternative was the original's per-frame allocation — but it means a bug inDisplayStringManager's cleanup path would leave a dangling static pointer on engine teardown. CurrentDisplayStringManagerkeeps its strings in a tracked list, so this is safe today but fragile to refactoring.