Skip to content

Conversation

@kagikn
Copy link
Member

@kagikn kagikn commented Dec 21, 2023

Resolves #1301

Summary

This commit adds the ability to display output strings with multiple lines to the console, making output strings will not always get overlapped even if there is no room for displaying in single line.

Uses binary search to avoid taking too long time to calculate where to split strings.

Do note that the initial commit does not make it possible to display long strings that needs to be pushed with more than 4 substrings where the length is up to 99 excluding the null bytes. This is probably because END_TEXT_COMMAND_DISPLAY_TEXT cannot display strings with more than 4 variable substrings (more than 397 bytes if all the characters in the string are ASCII ones). Maybe ScriptTextLinesFourSubstringsThreeNumbers in gameconfig.xml is relevant a bit? I'll try to find a way to draw long strings where more than 4 variable substrings has to be pushed in later commits.

This commit does not make a window for the current input since it isn't as important as the output window IMHO, the console still displays in a silly way if the input is too long to fit in a single line. Would be fixed when we start to use ImGui for the console though.

Showcase

271590_20231221215020_1
Type the lines below for each to test:

return "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. aaaaagg"
return "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur."
return "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. rrrrrrr"
return "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla ZZZZZZZap"
return "いやああここまでコンソールの出力で苦労するとは思いませんでした。でも397バイト以上のテキストを出力するにはまだまだ道は長いですね。あああああああもうやだああああああ!!!! まあImGui使うようになるまで努力しますけど。"

The last one need to set the game language to Japanese or use a font file for the font file for the language you use (the efigs one for most people who see this page?) where the Chalet 1960 London font has all the japanese glyphs of strings, before you can see the text.

I believe I could make the new code more clean, especially the pieces of it where how many spaces the console should keep for a output string and how to set text style variables...

…nsole

Resolves #1301
This commit adds the ability to display output strings with multiple
lines to the console, making output strings will not always get
overlapped even if there is no room for displaying in single line.

Uses binary search to avoid taking too long time to calculate where to
split strings.

Do note that this commit itself does not make it possible to display
long strings that needs to be pushed with more than 4 substrings where
the length is up to 99 excluding the null bytes. This is probably
because END_TEXT_COMMAND_DISPLAY_TEXT cannot display strings with more
than 4 variable substrings. Maybe ScriptTextLinesFourSubstringsThreeNumbers
in gameconfig.xml is relevant a bit?
@kagikn
Copy link
Member Author

kagikn commented Jan 9, 2024

Gosh, the display text script command, well I mean GFxParagraphFormatter::Format, tries to find line break opportunities (such as space characters) without providing ways to disable to find line break opportunities but just rely on the pure text width like how common terminals such as cmd.exe and Windows Terminal wraps the input string and output strings 🤡
I guess we have to split text outputs in an disgusting way to emulate the common way to display outouts in common terminals...

END_TEXT_COMMAND_GET_NUMBER_OF_LINES_FOR_STRING may return the same value as substring without the trailing whitespaces if the string ends with whitespace characters, so I think we have to use END_TEXT_COMMAND_GET_SCREEN_WIDTH_OF_DISPLAY_TEXT so the console can emulate how common terminals wraps the input string and output strings without doing the smart word wrap. Needs to be careful with color tokens and special character tokens such as wanted level star and Rockstar Games images though.

@kagikn
Copy link
Member Author

kagikn commented Jan 10, 2024

In script display text natives/commands, CTextFormat::GetStringWidth parses as a html document if there's some styling tokens, wanted star tokens, new line tokens, color tokens, and blip tokens starting from BLIP_ (case-sensitive), and otherwise parses as plain text document. So just manually inserting \n or <br> doesn't make GetStringWidth parse as a html document. This bothers me a lot if I would consider HTML tags, but I think we could ignore HTML <br> tags in output strings this time as we would replace the console with one made with ImGui in the future...

For those who wants to parse HTML <br> tags for script display text natives/commands, the info in the comment block below may be useful:

// When you see '<', make sure the following character is not '!' or '/' before parsing as a start element.
// When you skip HTML comments, unlike html documents in the Web, keep in mind that hyphens do not have to be placed other than before an ending greater than character 
// (e.g. `<!br-->` will be skipped as comments but `<!--br->` will be parsed as an HTML br tag). 
// See `GFxSGMLParser<Char>::SkipComment` in `GFxSGMLParser.h` in that secret code to verify how the game would skip html comments.
// When you parse an starting HTML element, you may have to skip space characters and the set below contains all the characters that the scaleform treats as space characters!

// This set contains all the values where `G_iswspace` in `Gstd.h` would return non-zero values and the game would treat as space characters!
HashSet<char> SpaceCharactersForScaleform = new() { '\u0009', '\u000a', '\u000b', '\u000c', '\u000d', '\u0020', '\u00a0', '\u1680', '\u2000', '\u2001', '\u2002', '\u2003', '\u2004', '\u2005', '\u2006', '\u2007', '\u2008', '\u2009', '\u200a', '\u200b', '\u2028', '\u2029', '\u202f', '\u3000' }; 

@kagikn
Copy link
Member Author

kagikn commented Jan 15, 2024

as a memo, some stuff to parse tokens output strings to avoid tearing tokens apart:

public enum TokenStateOfParseToken
{
    /// <summary>
    /// standard text (not found a token yet)
    /// </summary>
    StandardArea,
    /// <summary>
    /// inside a token, gathering all the token text
    /// </summary>
    InsideTokenArea,
    /// <summary>
    /// fully completed, ready to go back to standard text again
    /// </summary>
    Completed,
}

const char TokenEscape = '\\';
const char TokenDelimiter = '~';

public static List<string> SplitStringByNewLineTokens(string str)
{
    // same max length as `MAX_TOKEN_LEN` macro in `TextFormat.h` but without the null terminator
    const int MaxTokenLen = 63;
    StringBuilder tokenNameBuffer = new StringBuilder(MaxTokenLen);

    TokenStateOfParseToken tokenState = TokenStateOfParseToken.StandardArea;
    char prevChar = '\0';

    int i = 0;

    int startPosOfSubstring = 0;
    int endPosOfSubstring = 0;
    List<string> substrings = new List<string>();

    while (i < str.Length)
    {
        char newCharacter = str[i];
        if (newCharacter == TokenEscape && (i + 1) < str.Length)
        {
            // Get the next character. If valid (not null), check if it is the delimiter.
            // If it is the delimiter, skip this escape character. The code below
            // will handle treating the delimiter as a normal character.

            // NOTE: the next token delimiter will be added as a part of the token name if the token state is
            // inside token area, but this behavior is exactly the same as how the game parses.
            char peek = str[i + 1];
            if (peek == TokenDelimiter)
            {
                prevChar = newCharacter;
                i++;
                continue;
            }
        }

        if (newCharacter == TokenDelimiter && (i == 0 || prevChar != TokenEscape))
        {
            if (tokenState == TokenStateOfParseToken.StandardArea)
            {
                tokenState = TokenStateOfParseToken.InsideTokenArea;
                prevChar = newCharacter;
                endPosOfSubstring = i;
                i++;
                continue;
            }
            else if (tokenState == TokenStateOfParseToken.InsideTokenArea)
            {
                string tokenName = tokenNameBuffer.ToString();
                tokenNameBuffer.Clear();
                
                tokenState = TokenStateOfParseToken.StandardArea;
                prevChar = newCharacter;

                // The new line token IS case-sensitive in the game unlike how the bold and italic style
                // tokens, wanted star token, Rockstar logo token, and non-renderable token (~nrt~) are parsed.
                if (tokenName == "n")
                {
                    substrings.Add(str.Substring(startPosOfSubstring, endPosOfSubstring - startPosOfSubstring));

                    i++;
                    startPosOfSubstring = i;
                    endPosOfSubstring = i;
                }
                else
                {
                    i++;
                    endPosOfSubstring = i;
                }
            }
        }
        else if (tokenState == TokenStateOfParseToken.InsideTokenArea)
        {
            // Emulate how the game appends the token name to the token name buffer 😛
            if (tokenNameBuffer.Length < MaxTokenLen)
            {
                tokenNameBuffer.Append(newCharacter);
            }

            prevChar = newCharacter;
            i++;
        }
        else if (tokenState == TokenStateOfParseToken.StandardArea)
        {
            prevChar = newCharacter;
            i++;
            endPosOfSubstring = i;
        }
    }

    endPosOfSubstring = i;
    if (endPosOfSubstring - startPosOfSubstring > 0)
    {
        substrings.Add(str.Substring(startPosOfSubstring, endPosOfSubstring - startPosOfSubstring));
    }

    return substrings;
}
 
public static List<RangeCompact> FindAllTokenRanges(string str)
{
    TokenStateOfParseToken tokenState = TokenStateOfParseToken.StandardArea;
    char prevChar = '\0';

    int i = 0;

    int startPosOfToken = 0;
    int endPosOfToken = 0;
    List<RangeCompact> ranges = new List<RangeCompact>();

    while (i < str.Length)
    {
        char newCharacter = str[i];
        if (newCharacter == TokenEscape && (i + 1) < str.Length)
        {
            // Get the next character. If valid (not null), check if it is the delimiter.
            // If it is the delimiter, skip this escape character. The code below
            // will handle treating the delimiter as a normal character.
            char peek = str[i + 1];
            if (peek == TokenDelimiter)
            {
                prevChar = newCharacter;
                i++;
                // endPosOfToken will be set to i + 1 later
                continue;
            }
        }

        if (newCharacter == TokenDelimiter && (i == 0 || prevChar != TokenEscape))
        {
            if (tokenState == TokenStateOfParseToken.StandardArea)
            {
                tokenState = TokenStateOfParseToken.InsideTokenArea;
                prevChar = newCharacter;
                startPosOfToken = i;
                endPosOfToken = i + 1;
                i++;
                continue;
            }
            else if (tokenState == TokenStateOfParseToken.InsideTokenArea)
            {
                tokenState = TokenStateOfParseToken.StandardArea;
                prevChar = newCharacter;
                ranges.Add(new RangeCompact(startPosOfToken, endPosOfToken + 1));

                i++;
                startPosOfToken = i;
                endPosOfToken = i;
            }
        }
        else if (tokenState == TokenStateOfParseToken.InsideTokenArea)
        {
            prevChar = newCharacter;
            i++;
            endPosOfToken = i;
        }
        else if (tokenState == TokenStateOfParseToken.StandardArea)
        {
            prevChar = newCharacter;
            i++;
        }
    }

    if (endPosOfToken - startPosOfToken > 0)
    {
        ranges.Add(new RangeCompact(startPosOfToken, endPosOfToken + 1));
    }

    return ranges;
}
 
public readonly struct RangeCompact : IEquatable<RangeCompact>
{
    /// <summary>Represent the inclusive start index of the Range.</summary>
    public int Start { get; }

    /// <summary>Represent the exclusive end index of the Range.</summary>
    public int End { get; }

    /// <summary>Construct a Range object using the start and end indexes.</summary>
    /// <param name="start">Represent the inclusive start index of the range.</param>
    /// <param name="end">Represent the exclusive end index of the range.</param>
    public RangeCompact(int start, int end)
    {
        Start = start;
        End = end;
    }

    /// <summary>Indicates whether the current Range object is equal to another object of the same type.</summary>
    /// <param name="value">An object to compare with this object.</param>
    public override bool Equals(object? value) =>
        value is RangeCompact r &&
        r.Start.Equals(Start) &&
        r.End.Equals(End);

    /// <summary>Indicates whether the current Range object is equal to another Range object.</summary>
    /// <param name="other">An object to compare with this object</param>
    public bool Equals(RangeCompact other) => other.Start.Equals(Start) && other.End.Equals(End);

    /// <summary>Returns the hash code for this instance.</summary>
    public override int GetHashCode()
    {
        return Start.GetHashCode() * 39 + End.GetHashCode();
    }
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add the ability to display long outputs with multiple lines

2 participants