The Mission Text That Never Rendered
A ghost feature — state was set, state was stored, state was queried. The render block was missing. Two years of silent failure.
Video length: ~5:15 Setup: VS Code on the left, in-game footage on the right. Prereqs: Basic feel for how a game frame is split into script tick, logic tick, and render.
Cold open (0:00-0:30)
VISUAL: Mission cutscene. Camera pans down a ridge. Music swells. The letterbox bars slide in. A general's name should appear in the bottom bar.
Nothing.
The music keeps playing. The bars stay. Two seconds pass. Three. The camera moves on.
Say: "That subtitle is supposed to be there. It was there in the original game. The script that fires it still runs. The state it writes still lives in memory. Nothing on screen. No error. No log line. For two years nobody noticed, because the missions that use it are story beats you sit through once."
VISUAL: Cut to title card — "The Mission Text That Never Rendered."
Act 1 — The script action (0:30-1:30)
VISUAL: Open ScriptActions.cpp, jump to line 2622.
Say: "The feature is called DISPLAY_CINEMATIC_TEXT. It's a script action the mission designers call from the scripting tool. Here's the handler."
VISUAL: Highlight the function. Read the body slowly on screen.
void ScriptActions::doDisplayCinematicText(const AsciiString& displayText, const AsciiString& fontType, Int timeInSeconds) { UnicodeString uStr = TheGameText->fetch( displayText ); AsciiString aStr; aStr.translate( uStr ); TheDisplay->setCinematicText( aStr ); // ... parse fontType into name + size + bold flag ... GameFont *font = TheFontLibrary->getFont( fontName, TheGlobalLanguageData->adjustFontSize(size), bold ); TheDisplay->setCinematicFont( font ); Int frames = LOGICFRAMES_PER_SECOND * timeInSeconds; TheDisplay->setCinematicTextFrames( frames ); }
Say: "Three setters on TheDisplay. Resolve the string through the localization table. Parse a font spec like Arial - Size: 14 inline. Convert seconds to 30-Hz logic frames. Done. Clean. Nothing weird."
VISUAL: Highlight the three setCinematic... calls.
Say: "This is the producer. It runs, it writes state, it exits. Whoever consumes the state renders the text. That's the contract."
Act 2 — The state (1:30-2:30)
VISUAL: Open Display.h, scroll to the cinematic section.
Say: "Here's where those setters land."
virtual void setCinematicText( AsciiString string ) { m_cinematicText = string; } virtual void setCinematicFont( GameFont *font ) { m_cinematicFont = font; } virtual void setCinematicTextFrames( Int frames ) { m_cinematicTextFrames = frames; } AsciiString m_cinematicText; GameFont *m_cinematicFont; Int m_cinematicTextFrames;
Say: "Three members on the base Display class. Three setters that write them. The getters exist too. Straightforward. If you set a breakpoint on these setters during a mission, they hit on cue. String is correct. Font is resolved. Frame count is 150 for a five-second call."
VISUAL: Add a watch expression in the debugger. Show m_cinematicText populated.
Say: "Everything looks fine. The script fired. The state is sitting right there. So why is the screen empty?"
Act 3 — The search for the render code (2:30-3:30)
VISUAL: Open W3DDisplay.cpp. Jump to draw().
Say: "Text gets painted in the 2D pass. That lives in W3DDisplay::draw(). Let's find where it reads m_cinematicText."
VISUAL: Ctrl+F. Type m_cinematicText. No results in the pre-fix version.
Say: "No hits. Search the whole file. Search the whole GameEngineDevice project. m_cinematicText is written in three places. Read in exactly zero."
VISUAL: Scroll through draw() in the old version. Letterbox bars get drawn. Cursor gets drawn. Debug FPS. No cinematic text block.
Say: "This is what a ghost feature looks like. The script engine thinks it's working. Display thinks it's working. The data is set, it persists, it decrements — except nothing is decrementing it because nothing is reading it. It's just a member variable that never graduates into pixels. The D3D8-to-D3D11 port missed this block. The original draw path had it embedded. The rewrite didn't carry it over. No assert tripped because the setters don't know whether anyone's watching."
VISUAL: git log --follow on W3DDisplay.cpp. The commit that rewrote draw() for D3D11 is a thousand lines of diff. The cinematic block is simply absent from both sides.
Act 4 — The fix (3:30-4:30)
VISUAL: Open the current W3DDisplay.cpp at line 1075.
Say: "The fix is a new block in draw(), right after the letterbox bars. Guarded on non-empty text and a positive frame count."
if (m_cinematicText != AsciiString::TheEmptyString && m_cinematicTextFrames != 0 && TheDisplayStringManager) { 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(); const Bool textChanged = (s_lastText != m_cinematicText); const Bool fontChanged = (s_lastFont != m_cinematicFont); const Bool widthChanged = (s_lastDisplayWidth != getWidth()); if (textChanged || fontChanged || widthChanged) { s_cinematicDS->setFont(m_cinematicFont); s_cinematicDS->setWordWrap(getWidth() - 20); s_cinematicDS->setWordWrapCentered(TRUE); UnicodeString uText; uText.translate(m_cinematicText); s_cinematicDS->setText(uText); } // ... draw centered at 90% screen height ... }
Say: "One static DisplayString caches the glyph layout. Rebuild only when text, font, or screen width changes — so resizing the window retriggers wrap. The original allocated a fresh DisplayString every frame and leaked it; this version allocates once per process. White text, black drop shadow, centered horizontally, positioned at 90 percent of screen height so it lands inside the lower letterbox bar."
VISUAL: Run the mission. Bars slide in. Text appears. Music plays. Camera pans. Text fades after its duration.
Say: "Ghost feature, restored."
Act 5 — The render-rate bug (4:30-5:15)
VISUAL: Zoom to the bottom of the block.
Say: "There's one more subtlety. m_cinematicTextFrames is expressed in 30-Hz logic frames. The original engine throttled draw() internally to 30 Hz, so decrementing once per render call matched logic frames one-to-one."
Say: "We don't throttle draw(). On a 144-Hz monitor, a naive decrement burns through 150 frames in about one second. A five-second subtitle lasts one second."
if (m_cinematicTextFrames > 0 && TheGameLogic) { static UnsignedInt s_lastLogicFrame = ~0u; const UnsignedInt curLogicFrame = TheGameLogic->getFrame(); if (curLogicFrame != s_lastLogicFrame) { s_lastLogicFrame = curLogicFrame; --m_cinematicTextFrames; } }
Say: "Static s_lastLogicFrame remembers the last logic frame we decremented on. When TheGameLogic->getFrame() ticks over, we take one off the counter. Render rate stops mattering. Wall-clock duration is preserved whether the player is on 60, 144, or 240."
Outro
Say: "The takeaway. Silent features are invisible until someone plays the mission that uses them. No assert fired. No log line printed. The setters have no idea if anyone's listening on the other side — they just write to memory and hope."
Say: "Things that would've caught this in a week: a debug assert on the setter if the counter is still positive after an hour of gameplay. A per-subsystem 'did this render?' counter exposed in the perf overlay. A smoke test that fires the script action on boot and screen-scrapes for the pixels."
Say: "None of that existed. What existed was a play-through of a later mission where a tester said 'wait, wasn't there supposed to be a name there?' That was the bug report. That's how ghost features get found."
B-roll suggestions
- Side-by-side of the mission cutscene before and after, timestamped.
- Scroll through the old
draw()method while narrating the missing block — fast cut. - Debugger watch panel showing
m_cinematicTextpopulated while the screen is empty. git blameon the fix commit, hovering overs_lastLogicFrame.
Takeaway
State that nobody reads is worse than state that doesn't exist. A setter with no consumer looks identical to a working feature from the producer's side. Port work is where this hides — the data pipe survives, the render pipe doesn't, and nothing connects the two except a pair of eyes running the right mission.