Skip to content

Replicate original Mass Effect 1 subtitle behaviour #307

Description

@Herobrine24

Files: Startup_*.pcc, SFXGame.pcc

Needs fixed in localized file? Yes

Related issues:

Files modified/added for LE1 Community Patch:
LE1SubtitleFix-LE1CP.zip
Installation with M3 via LE1 Community Patch verified.

Background

In the original Mass Effect 1, GUI_SF_Conversation.Conversation was responsible for both the conversation wheel and subtitle rendering.

In later games, BioWare split subtitle rendering into a separate native-only Render Thread UI (FSFXGFx_RTGuiManager) while leaving the conversation wheel in the standard GFx UI.

For Legendary Edition, this Render Thread system was backported into LE1. Rather than introducing a dedicated subtitle UI, BioWare repurposed GUI_SF_LoadScreenToolTips.LoadScreenToolTips.

As a result, this UI now performs two unrelated functions:

  • Displays the "Loading" text during loading screens.
  • Displays subtitles during FMVs.

Because normal GFx UIs such as GUI_SF_Conversation.Conversation are not rendered over FMVs, all FMV subtitles are routed through this Render Thread UI instead.

This implementation introduces the issues described in #306.

In addition, the Render Thread UI only receives:

  • an HTML-formatted subtitle string (with an undefined color and a hardcoded font size of 22), and
  • a Boolean indicating whether the text is a subtitle or loading text.

Consequently, FMV subtitles ignore the user's configured subtitle size (including launcher settings and custom launch parameters such as those provided by ME3Tweaks Mod Manager), causing issue #133.

For executable reverse engineering, search for the string "setLoadingText" in Ghidra and inspect its references. The function that sends subtitle text to LoadScreenToolTips invokes the standard GFx InvokeMethod() API.


Proposed Solution

Instead of rendering FMV subtitles through GUI_SF_LoadScreenToolTips, redirect them back to GUI_SF_Conversation.Conversation, restoring a workflow much closer to the original Mass Effect 1 implementation.

Because the Render Thread interface only exposes two parameters (text and subtitle/loading state), subtitle formatting data must be embedded into the transmitted string and reconstructed by the receiving Scaleform movie.

The implementation consists of:

  1. Redirecting the Render Thread subtitle resource to GUI_SF_Conversation.Conversation.
  2. Adding a setLoadingText() entry point compatible with the Render Thread interface.
  3. Encoding subtitle formatting parameters into a hidden __PARAMS[...] prefix.
  4. Extending setSubtitle() to extract those parameters before rendering.
  5. Making the required Scaleform asset and layout adjustments.
  6. Updating BioSeqAct_FaceOnlyVO to prepend subtitle formatting information before dispatching subtitles.
  7. Adding a new "silent" FaceOnlyVO object to the Citadel arrival cinematic so its subtitles enter the Render Thread subtitle pipeline.

This is the closest solution to restoring the behaviour seen in the original Mass Effect 1 without resorting to ASI modding.


Implementation

1. Redirect the Render Thread subtitle resource

Modify FSFXGFx_RTGuiManager in BIOUI.ini

[BIOUI.ini FSFXGFx_RTGuiManager]
-FSFXGFx_RTLevelLoadTip_Resource=GUI_SF_LoadScreenToolTips.LoadScreenToolTips
+FSFXGFx_RTLevelLoadTip_Resource=GUI_SF_Conversation.Conversation

2. Add setLoadingText() to Startup_*.GUI_SF_Conversation.Conversation

function setLoadingText(txt, bIsSubtitle)
{
   HideWheel();
   // --- PRE-PROCESSOR: HTML TAG SCRUBBER ---
   // Loop to strip out closing </font> tags
   while (txt.indexOf("</font>") != -1) {
      var closeStart = txt.indexOf("</font>");
      txt = txt.substring(0, closeStart) + txt.substring(closeStart + 7);
   }

   // Loop to strip out dynamic opening <font ...> tags containing attributes
   while (txt.indexOf("<font") != -1) {
      var openStart = txt.indexOf("<font");
      var openEnd = txt.indexOf(">", openStart);
      
      if (openEnd != -1) {
         // Cut out everything from "<font" up to and including the closing ">"
         txt = txt.substring(0, openStart) + txt.substring(openEnd + 1);
      } else {
         // Fail-safe protection against malformed HTML tags to prevent infinite loops
         break;
      }
   }
   // ----------------------------------------
   // Apply structural fallback properties
   var finalSize = bIsSubtitle ? 22 : 16;
   var finalColor = bIsSubtitle ? 13434879 : 10079436;
   var finalAlpha = 255;
   var finalPosition = bIsSubtitle ? 1 : 5;
   var cleanTxt = txt;

   // Locate the start of the identifier tag
   var prefixStart = txt.indexOf("__PARAMS[");
   
   if (prefixStart != -1) 
   {
      // The actual values start 9 characters after the prefix start ("__PARAMS[" is 9 chars)
      var valueStart = prefixStart + 9;
      var prefixEnd = txt.indexOf("]", valueStart);
      
      if (prefixEnd != -1) 
      {
         // Extract data string isolated inside the brackets: "Size,R,G,B,A"
         var dataRaw = txt.substring(valueStart, prefixEnd);
         var dataArr = dataRaw.split(",");
         
         // Ensure all 5 properties exist in the sequence
         if (dataArr.length >= 5) {
            finalSize  = Number(dataArr[0]);
            
            var r = Number(dataArr[1]);
            var g = Number(dataArr[2]);
            var b = Number(dataArr[3]);
            
            // Standard bitwise composition to map RGB array bytes to decimal color codes
            finalColor = (r << 16) | (g << 8) | b;
            
            // Dynamically assign the extracted Alpha value from the 5th element
            finalAlpha = Number(dataArr[4]);
         }
         
         // Wipe the data tag out completely by starting right after the closing bracket "]"
         cleanTxt = txt.substring(prefixEnd + 1);
      }
   }
   
   setSubtitle(cleanTxt, finalPosition, finalColor, finalAlpha, false, finalSize);
}

This function serves as the Render Thread entry point. It:

  • hides the conversation wheel,
  • removes BioWare's HTML formatting tags,
  • extracts subtitle formatting from the embedded __PARAMS[...] payload when present,
  • applies fallback formatting when it is absent, and
  • forwards the processed data to setSubtitle().

3. Extend setSubtitle()

Replace setSubtitle() with the implementation below.

function setSubtitle(sSubtitle, nPosition, nColor, nAlpha, bBackground, nSize)
{
   ExternalInterface.Call("ASLog","CONV SET STRING: " + sSubtitle);
   ExternalInterface.Call("ASLog","CONV SET SIZE: " + nSize);
   var _loc7_;
   var _loc8_;
   var _loc9_;
   var _loc10_;
   var _loc11_;
   var _loc12_;
   var _loc13_;
   
   if(sSubtitle == "")
   {
      HideSubtitle();
   }
   // Intercept if the string was passed from BioSFHandler_Conversation
   else if (sSubtitle.indexOf("__PARAMS[") != -1)
   {
      var prefixStart = sSubtitle.indexOf("__PARAMS[");
      var valueStart = prefixStart + 9;
      var prefixEnd = sSubtitle.indexOf("]", valueStart);
      
      if (prefixEnd != -1) 
      {
         var dataRaw = sSubtitle.substring(valueStart, prefixEnd);
         var dataArr = dataRaw.split(",");
         
         if (dataArr.length >= 5) {
            // Override incoming layout parameters with the hidden native data values
            nSize = Number(dataArr[0]);
            
            var r = Number(dataArr[1]);
            var g = Number(dataArr[2]);
            var b = Number(dataArr[3]);
            nColor = (r << 16) | (g << 8) | b;
            
            nAlpha = Number(dataArr[4]);
         }
         
         // Scrub the payload string clean to prevent UI leaks
         sSubtitle = sSubtitle.substring(prefixEnd + 1);
      }
      
      // Re-route the cleaned text and data recursively into this function to execute the display block safely
      setSubtitle(sSubtitle, nPosition, nColor, nAlpha, bBackground, nSize);
   }
   else
   {
      switch(nPosition)
      {
         case 0:
            _loc7_ = SubtitleBottom;
            _loc8_ = SubtitleTop;
            _loc9_ = SubtitleConversation;
            _loc10_ = loadingText;
            break;
         case 1:
            _loc7_ = SubtitleTop;
            _loc8_ = SubtitleBottom;
            _loc9_ = SubtitleConversation;
            _loc10_ = loadingText;
            break;
         case 2:
            _loc7_ = SubtitleConversation;
            _loc8_ = SubtitleBottom;
            _loc9_ = SubtitleTop;
            _loc10_ = loadingText;
            break;
         case 5:
            _loc7_ = loadingText;
            _loc8_ = SubtitleBottom;
            _loc9_ = SubtitleTop;
            _loc10_ = SubtitleConversation;
      }
      _loc7_._visible = true;
      _loc11_ = _loc7_.SubtitleText.getTextFormat();
      _loc11_.size = nSize;
      _loc7_.SubtitleText.setTextFormat(_loc11_);
      if(nPosition != 1)
      {
         if(bBackground)
         {
            _loc7_.gotoAndStop(2);
            _loc12_ = _loc7_.SubtitleText._y + _loc7_.SubtitleText._height / 2;
            com.TextFieldEx.SetText(_loc7_.SubtitleText,sSubtitle);
            _loc7_.SubtitleText.autoSize = "center";
            _loc7_.SubtitleText._y = _loc12_ - _loc7_.SubtitleText._height / 2;
            if(ConversationWheel._visible)
            {
               _loc7_._y -= 120;
            }
         }
         else
         {
            _loc7_.gotoAndStop(1);
            com.TextFieldEx.SetText(_loc7_.SubtitleText,sSubtitle);
            _loc7_.SubtitleText.autoSize = "center";
            if(nPosition == 5)
            {
               _loc7_.SubtitleText.autoSize = "left";
            }
            else
            {
               _loc7_.SubtitleText.autoSize = "center";
            }
            _loc7_.SubtitleText._y = lstTextStartY[nPosition] - (_loc7_.SubtitleText._height - lstTextStartHeight[nPosition]);
         }
      }
      else
      {
         _loc7_.SubtitleText._y = lstTextStartY[nPosition];
         com.TextFieldEx.SetText(_loc7_.SubtitleText,sSubtitle);
         _loc7_.SubtitleText.autoSize = "center";
      }
      _loc13_ = nAlpha * 0.39215686274509803;
      _loc7_.SubtitleText.textColor = nColor;
      _loc7_.SubtitleText._alpha = _loc13_;
      _loc7_.SubtitleText._quality = "BEST";
      _loc8_._visible = false;
      _loc9_._visible = false;
      _loc10_._visible = false;
   }
}

The updated implementation serves as a protection against displaying __PARAMS[...] in FOVO triggered subtitles outside of FMVs by stripping it and calling itself again.

4. Update GUI_SF_Conversation.Conversation.gfx resources

Because GUI_SF_Conversation now assumes responsibility for both loading text and FMV subtitles, the loading text instance must be modified to match the layout previously provided by GUI_SF_LoadScreenToolTips. These adjustments preserve the original loading screen appearance while allowing the movie to serve both purposes.

A: Clone ..\other\ImportAssets2 (chid: 103, imp: "$Myriad") and update its properties as follows:

assets
└── tag[0] :  U16 = 103, name[0] : String = "$BioMass"

This font is used by the "Loading" text.


B: Modify ..\frames\frame 1\PlaceObject2 (chid: 99, dpt: 71, nm: "loadingText") as follows:

matrix : MATRIX
├── hasScale : boolean = true
├── scaleX : FB[nScaleBits] = 1.0000457763671875
├── scaleY : FB[nScaleBits] = 1.0004119873046875
├── translateX : SB[nTranslateBits] = 1812
└── translateY : SB[nTranslateBits] = 12784

C: Disable transform matrix of ..\sprites\DefineSprite (chid: 99)\frame 1\PlaceObject2 (chid 98, dpt: 1, nm: "SubtitleText") as follows:

├── placeFlagHasMatrix : boolean = false

Steps B & C ensure the scaling and position match the original.


D: Rebind ..\texts\DefineEditText (chid 98) to $BioMass using Raw edit via the context menu on the DefineEditText and making the following change:

├── fontId : UI16 = 103

8. Preserve subtitle formatting through BioSeqAct_FaceOnlyVO

Extend BioSeqAct_FaceOnlyVO with the following UnrealScript implementation.

event function Activated()
{
    if (m_eConvLine == EBioFOVOLines.FOVOLines_Unset)
    {
        SetSilent();
    }
    SetRTSubtitleParams();
    Super(SequenceOp).Activated();
}
function SetSilent()
{
    m_pSoundCue = None;
}
function SetRTSubtitleParams()
{
    local BioSubtitles Subs;
    
    Subs = BioWorldInfo(Class'Engine'.static.GetCurrentWorldInfo()).m_Subtitles;
    if (Subs != None)
    {
        m_sSubtitle = "__PARAMS[" $ Subs.m_FontSize $ "," $ Subs.m_FontColor.R $ "," $ Subs.m_FontColor.G $ "," $ Subs.m_FontColor.B $ "," $ Subs.m_FontColor.A $ "]" $ m_sSubtitle;
    }
}

Before dispatching the subtitle, the active formatting values are read from BioSubtitles and serialized into a hidden __PARAMS[...] prefix.


9. Add dedicated "silent" FaceOnlyVO for the Citadel arrival FMV

In BIOA_STA20_A_Arrival_CIN.TheWorld.PersistentLevel.Main_Sequence.ANIMCUTSCENE_STA_20_A_Arrival_SEQ04 via the Sequence Editor...

  • Add a new FaceOnlyVO object by cloning an existing one
  • Hook #275 Interp_1 from output PlaySEQ03 to the cloned FaceOnlyVO's Play input
  • Hook the cloned FaceOnlyVO's Pawn Variable to #111 BioInert_4 Invisible_Type
  • In the cloned FaceOnlyVO, change m_eConvLine to FOVOLines_Unset
  • In the cloned FaceOnlyVO, change m_aObjectComment[0] to Citadel Control, this is SSV Normandy requesting permission to land.

This FOVO will now play at the same time the FMV starts, causing the Render Thread subtitle to take over, making the transition seamless.


Known Limitation

One known edge case remains at the end of Eden Prime during the Prothean Beacon sequence.

Unlike most cinematics, this sequence does not use either BioConversation or FaceOnlyVO objects. Instead, subtitle timing is driven directly by the Interp in BIOA_PRO10_11_DSG.TheWorld.PersistentLevel.Main_Sequence.Combats.FinalFightOver.ANIMCUTSCENE_PRO_20_C_Artifact_male, meaning the subtitles still pass through the Render Thread path without the additional formatting data.

Resolving this would require restructuring the cinematic to introduce FaceOnlyVO events solely to drive subtitle generation. Given that this FMV is extremely brief and visually busy, the additional work is unlikely to justify the benefit.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions