From e1989e68238ce92946b226d8888de6cf372f67dc Mon Sep 17 00:00:00 2001 From: carrdylan52 Date: Mon, 5 Oct 2026 23:40:52 -0400 Subject: [PATCH 1/3] Write the port's own text in the game's message font GameTextEncode turns UTF-8 into the s16 codes ClsMes draws, by the PAL font's table read off every language's gaiji.img and checked against every message file on the disc. GameTextFile lays texts out as a message file for ClsMes::SetBuff, and GameText draws one piece of text as the menus draw their help line, for labels, values and help in the Options screen. --- docs/PC.md | 29 +++ port/src/gametext.cpp | 327 +++++++++++++++++++++++++++++++ port/src/gametext.hpp | 83 ++++++++ port/src/tests/gametext_test.cpp | 88 +++++++++ 4 files changed, 527 insertions(+) create mode 100644 port/src/gametext.cpp create mode 100644 port/src/gametext.hpp create mode 100644 port/src/tests/gametext_test.cpp diff --git a/docs/PC.md b/docs/PC.md index 4acacf932..cbc0f5085 100644 --- a/docs/PC.md +++ b/docs/PC.md @@ -774,6 +774,35 @@ retail's 0x136A7-byte image. `sce/sifdev.cpp` implements `sceOpen`, device prefix stripped (the debug dump `edit.cpp` writes to `host0:`), and `WriteFile` writes there too. +## Game text + +`port/src/gametext.cpp` writes the port's own text in the game's message +font, so menus the port adds can match the game's. A message is an s16 code +per character ending in `MES_CODE_END` (-0xFF); a space is +`MES_CODE_SPACE` (-0xFE) and a line break `MES_CODE_NEWLINE` (-0x100). The +PAL font draws only the external characters from -0x300: -0x300 to -0x2E0 +are the pad buttons and icons; -0x2DF to -0x288 are the 88 cells of +`gaiji.img`'s letter grid in order (`A`-`Z`, `a`-`z`, +`'="!?#&+-*/%()@|<>{}[]:,.$` and `0`-`9`); -0x287 to -0x254 are `œ ¡ ¿ ß Œ +Ç ç` and the letters `DrawGaijiFont` draws an accent over (`ÀÁÂÄÈÉÊËÌÍÎÏÑ +ÒÓÔÖÙÚÛÜàáâäèéêëìíîïñòóôöùúûü`). -0x263 to -0x261, -0x253 and -0x252 +draw a bare `?`. There is no `;`, `_`, `~`, `^`, backquote or backslash. +The table was read off the `gaiji.img` of every `meswin/mes_tex_N.pak` (all +seven draw these characters in the same cells; only the pixels differ) and +checked by decoding every `.mes` on the disc: each language uses only +characters in it, and its own accents only. + +`GameTextEncode` turns UTF-8 into codes. Curly quotes and dashes become the +font's own; any other character the font lacks, and any byte that is not +UTF-8, becomes `?` and is counted. `{N}` writes code N as it is, for control +codes and icons, and `{{` a `{`; `GameTextDecode` writes the same form, so +any message reads back and encodes to the same codes. `GameTextFile` lays +texts out as a message file for `ClsMes::SetBuff`. `GameText` is one piece of +text drawn as the menus draw their help line (`InitMenuMesSet`'s +`CommonMenuMes2`: the menu font, white with a black edge, shown whole): `Set` +the text, `SetColour` a `FontColor`, then `Draw(x, y, alpha)` from a menu's +draw function; `Width` gives its width for aligning a value. + ## Arenas `CDataAlloc2<1>::Alloc/Alloc64/Align64` and the carving in diff --git a/port/src/gametext.cpp b/port/src/gametext.cpp new file mode 100644 index 000000000..b067b6719 --- /dev/null +++ b/port/src/gametext.cpp @@ -0,0 +1,327 @@ +#include "gametext.hpp" + +#include +#include + +#include "gameutil.hpp" +#include "main.hpp" +#include "menu_draw.hpp" + +namespace { + +// gaiji.img's letter grid in code order: nine 14x20 cells a row from u 128 (GaijiDataTbl). +constexpr std::u32string_view kGrid = U"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'=\"!?#&+-*/%()@|<>{}[]:,.$0123456789"; +constexpr s16 kGridFirst = -0x2DF; + +struct Extra { + s16 code; + char32_t ch; +}; + +// The rest of PAL's characters: a few cells of their own below the grid, and grid letters that +// DrawGaijiFont draws an accent over. -0x263 to -0x261, -0x253 and -0x252 draw a bare '?'. +// clang-format off +constexpr Extra kExtras[] = { + {-0x287, U'œ'}, {-0x286, U'¡'}, {-0x285, U'¿'}, {-0x284, U'Ä'}, {-0x283, U'Ç'}, {-0x282, U'È'}, + {-0x281, U'É'}, {-0x280, U'Ö'}, {-0x27F, U'Ü'}, {-0x27E, U'ß'}, {-0x27D, U'à'}, {-0x27C, U'á'}, + {-0x27B, U'â'}, {-0x27A, U'ä'}, {-0x279, U'ç'}, {-0x278, U'è'}, {-0x277, U'é'}, {-0x276, U'ê'}, + {-0x275, U'ë'}, {-0x274, U'ì'}, {-0x273, U'í'}, {-0x272, U'î'}, {-0x271, U'ï'}, {-0x270, U'ñ'}, + {-0x26F, U'ò'}, {-0x26E, U'ó'}, {-0x26D, U'ô'}, {-0x26C, U'ö'}, {-0x26B, U'ù'}, {-0x26A, U'ú'}, + {-0x269, U'û'}, {-0x268, U'ü'}, {-0x267, U'Ú'}, {-0x266, U'Á'}, {-0x265, U'Œ'}, {-0x264, U'Ó'}, + {-0x260, U'À'}, {-0x25F, U'Â'}, {-0x25E, U'Ï'}, {-0x25D, U'Í'}, {-0x25C, U'Ì'}, {-0x25B, U'Î'}, + {-0x25A, U'Ù'}, {-0x259, U'Û'}, {-0x258, U'Ë'}, {-0x257, U'Ê'}, {-0x256, U'Ò'}, {-0x255, U'Ô'}, + {-0x254, U'Ñ'}, +}; +// clang-format on + +constexpr s16 kUnknown = kGridFirst + static_cast(kGrid.find(U'?')); + +// The code point at utf8[at], advancing at past it; U+FFFD for a byte that does not start a +// well-formed sequence, advancing one byte. +char32_t NextChar(std::string_view utf8, size_t &at) { + const auto lead = static_cast(utf8[at]); + int length; + char32_t ch; + char32_t least; + + if (lead < 0x80) { + at++; + return lead; + } + if ((lead & 0xE0) == 0xC0) { + length = 2; + ch = lead & 0x1F; + least = 0x80; + } else if ((lead & 0xF0) == 0xE0) { + length = 3; + ch = lead & 0x0F; + least = 0x800; + } else if ((lead & 0xF8) == 0xF0) { + length = 4; + ch = lead & 0x07; + least = 0x10000; + } else { + at++; + return 0xFFFD; + } + if (at + length > utf8.size()) { + at++; + return 0xFFFD; + } + for (int i = 1; i < length; i++) { + const auto next = static_cast(utf8[at + i]); + if ((next & 0xC0) != 0x80) { + at++; + return 0xFFFD; + } + ch = ch << 6 | (next & 0x3F); + } + if (ch < least || ch > 0x10FFFF || (ch >= 0xD800 && ch <= 0xDFFF)) { + at++; + return 0xFFFD; + } + at += length; + return ch; +} + +void AppendUtf8(std::string &out, char32_t ch) { + if (ch < 0x80) { + out += static_cast(ch); + } else if (ch < 0x800) { + out += static_cast(0xC0 | ch >> 6); + out += static_cast(0x80 | (ch & 0x3F)); + } else if (ch < 0x10000) { + out += static_cast(0xE0 | ch >> 12); + out += static_cast(0x80 | (ch >> 6 & 0x3F)); + out += static_cast(0x80 | (ch & 0x3F)); + } else { + out += static_cast(0xF0 | ch >> 18); + out += static_cast(0x80 | (ch >> 12 & 0x3F)); + out += static_cast(0x80 | (ch >> 6 & 0x3F)); + out += static_cast(0x80 | (ch & 0x3F)); + } +} + +// "{N}" at utf8[at]: N into code and at past the '}'. +bool ReadEscape(std::string_view utf8, size_t &at, s16 &code) { + size_t i = at + 1; + bool negative = false; + long value = 0; + size_t digits = 0; + + if (i < utf8.size() && utf8[i] == '-') { + negative = true; + i++; + } + while (i < utf8.size() && utf8[i] >= '0' && utf8[i] <= '9' && digits < 6) { + value = value * 10 + (utf8[i] - '0'); + digits++; + i++; + } + if (digits == 0 || i >= utf8.size() || utf8[i] != '}') { + return false; + } + value = negative ? -value : value; + if (value < INT16_MIN || value > INT16_MAX) { + return false; + } + code = static_cast(value); + at = i + 1; + return true; +} + +} // namespace + +s16 GameTextCode(char32_t ch) { + switch (ch) { + case U' ': + case U' ': + return MES_CODE_SPACE; + case U'\n': + return MES_CODE_NEWLINE; + case U'‘': + case U'’': + ch = U'\''; + break; + case U'“': + case U'”': + ch = U'"'; + break; + case U'–': + case U'—': + ch = U'-'; + break; + } + + const size_t at = kGrid.find(ch); + if (at != std::u32string_view::npos) { + return static_cast(kGridFirst + at); + } + for (const Extra &extra : kExtras) { + if (extra.ch == ch) { + return extra.code; + } + } + return 0; +} + +char32_t GameTextChar(s16 code) { + switch (code) { + case MES_CODE_SPACE: + return U' '; + case MES_CODE_NEWLINE: + return U'\n'; + } + + if (code >= kGridFirst && code < kGridFirst + static_cast(kGrid.size())) { + return kGrid[code - kGridFirst]; + } + for (const Extra &extra : kExtras) { + if (extra.code == code) { + return extra.ch; + } + } + return 0; +} + +int GameTextEncode(std::string_view utf8, std::vector &out) { + int missing = 0; + size_t at = 0; + + while (at < utf8.size()) { + if (utf8[at] == '{') { + if (at + 1 < utf8.size() && utf8[at + 1] == '{') { + out.push_back(GameTextCode(U'{')); + at += 2; + continue; + } + + s16 code; + if (ReadEscape(utf8, at, code)) { + out.push_back(code); + continue; + } + } + + s16 code = GameTextCode(NextChar(utf8, at)); + if (code == 0) { + code = kUnknown; + missing++; + } + out.push_back(code); + } + + out.push_back(MES_CODE_END); + return missing; +} + +std::string GameTextDecode(const s16 *codes) { + std::string text; + + for (; *codes != MES_CODE_END; codes++) { + const char32_t ch = GameTextChar(*codes); + + if (ch == 0) { + text += '{' + std::to_string(*codes) + '}'; + } else if (ch == U'{') { + text += "{{"; + } else { + AppendUtf8(text, ch); + } + } + + return text; +} + +int GameTextFile::Set(int id, std::string_view utf8) { + std::vector codes; + const int missing = GameTextEncode(utf8, codes); + + messages_[id] = std::move(codes); + dirty_ = true; + return missing; +} + +short *GameTextFile::Data() { + if (dirty_) { + // ClsMes::GetTextLineDataTop finds a message at &buff[1 + count] plus its s16 offset, so + // the messages that would start past that reach are left out. SetBuff takes buff[1] as an + // offset to a text pointer nothing reads. + size_t count = 0; + size_t length = 0; + for (const auto &message : messages_) { + if (1 + messages_.size() + length > INT16_MAX) { + break; + } + length += message.second.size(); + count++; + } + + data_.assign(2 + count * 2, 0); + data_[0] = static_cast(count); + + auto message = messages_.begin(); + for (size_t i = 0; i < count; i++, message++) { + data_[2 + i * 2] = static_cast(message->first); + data_[3 + i * 2] = static_cast(data_.size() - (1 + count)); + data_.insert(data_.end(), message->second.begin(), message->second.end()); + } + + dirty_ = false; + } + + return data_.data(); +} + +GameText::GameText() : colour_(FONT_COLOR_WHITE) { + mes_.Preset(MES_PRESET_SYSTEM); + mes_.char_width = 11; + mes_.char_height = 0x14; + mes_.narrow_gaiji_set = 2; + mes_.tex_block = 0x1A; + mes_.auto_pos = MES_POS_NONE; + mes_.tail_on = false; + mes_.value_show = false; + // The menus centre a help line in its window's rows; here the caller places each line. + mes_.centre_rows = false; +} + +int GameText::Set(std::string_view utf8) { + if (utf8 != text_ || mes_.mes_made < 0) { + text_ = utf8; + missing_ = file_.Set(0, utf8); + Layout(); + } + + return missing_; +} + +void GameText::SetColour(u32 colour) { + if (colour != colour_) { + colour_ = colour; + // The colour goes into each laid-out character, so it takes a new layout. + if (mes_.mes_made >= 0) { + Layout(); + } + } +} + +void GameText::Layout() { + mes_.SetBuff(file_.Data()); + if (SystemMes != nullptr) { + mes_.SetBuff_system(SystemMes); + } + mes_.clut_default = Color2Clut(colour_) & 0xFF; + mes_.clut_now = mes_.clut_default; + mes_.mes_made = -1; + mes_.MakeMesWin(0); +} + +void GameText::Draw(int x, int y, int alpha) { + if (mes_.mes_made < 0) { + return; + } + + mes_.edge_alpha = alpha; + DrawMenuClsMes(&mes_, x, y); +} diff --git a/port/src/gametext.hpp b/port/src/gametext.hpp new file mode 100644 index 000000000..47ba29cb2 --- /dev/null +++ b/port/src/gametext.hpp @@ -0,0 +1,83 @@ +#pragma once + +#include +#include +#include +#include +#include + +#include "clsmes.hpp" + +// Port text in the game's own message font. The PAL font is the 88 characters of gaiji.img's +// letter grid (codes -0x2DF to -0x288) and the accented letters DrawGaijiFont builds from them +// with an accent drawn over (to -0x254); every PAL language's mes_tex_N.pak draws the same +// characters at the same codes, so one table serves them all. + +// The code the font draws ch with, or 0 where it has none. ' ' is MES_CODE_SPACE and '\n' +// MES_CODE_NEWLINE. +s16 GameTextCode(char32_t ch); + +// The character code draws, or 0 where code is not one (a control code, an icon). +char32_t GameTextChar(s16 code); + +// Appends utf8's codes and MES_CODE_END to out. "{N}" puts the code N (decimal, as the game's +// s16) in as it is, for control codes and icons; "{{" is a '{'. Typographic quotes and dashes +// become the font's own. A character the font lacks, or a byte that is not UTF-8, becomes '?'; +// gives back how many did. +int GameTextEncode(std::string_view utf8, std::vector &out); + +// The text of codes up to MES_CODE_END, as GameTextEncode reads it back: a code that is not a +// character is written as "{N}" and '{' as "{{". +std::string GameTextDecode(const s16 *codes); + +// A message file of port text, laid out as ClsMes::SetBuff reads one: the count, then each +// message's number and where its text starts, then the texts. +class GameTextFile { +public: + // Sets message id's text; gives back how many characters GameTextEncode replaced. + int Set(int id, std::string_view utf8); + + // The file, for ClsMes::SetBuff; valid until the next Set. + short *Data(); + +private: + std::map> messages_; + std::vector data_; + bool dirty_ = true; +}; + +// One piece of port text drawn as the menus draw their help line (CommonMenuMes2 under +// InitMenuMesSet(MENU_MES_SET_ALLMENU)): the menu font, white with a black edge, shown whole. +// Each holds its own ClsMes, so labels, values and a help line are one each. +class GameText { +public: + GameText(); + GameText(const GameText &) = delete; + GameText &operator=(const GameText &) = delete; + + // Lays the text out again when it changed; gives back how many characters became '?'. + int Set(std::string_view utf8); + + // A FontColor, as FontColorTbl holds them; FONT_COLOR_WHITE until set. + void SetColour(u32 colour); + + // Draws the text with its first line's top-left corner at (x, y) on the game's 640-wide + // screen, at alpha (0x80 solid), as DrawMenuClsMes does. Call it from a menu's draw function + // once the message textures are loaded, as for CommonMenuMes2. + void Draw(int x, int y, int alpha = 0x80); + + // How wide the widest line draws, in screen pixels; for right-aligning or centring a value. + int Width() const { return mes_.text_width; } + + // The window itself, for settings the presets above do not cover. + ClsMes &Mes() { return mes_; } + +private: + void Layout(); + + ClsMes mes_; + GameTextFile file_; + std::string text_; + u32 colour_; + int missing_ = 0; +}; diff --git a/port/src/tests/gametext_test.cpp b/port/src/tests/gametext_test.cpp new file mode 100644 index 000000000..77f03b437 --- /dev/null +++ b/port/src/tests/gametext_test.cpp @@ -0,0 +1,88 @@ +#include + +#include +#include + +#include "clsmes.hpp" +#include "gametext.hpp" + +namespace { + +std::vector Encode(std::string_view text, int *missing = nullptr) { + std::vector codes; + const int replaced = GameTextEncode(text, codes); + if (missing != nullptr) { + *missing = replaced; + } + return codes; +} + +} // namespace + +// Place names as the PAL disc's meswin/system_N.mes spells them (messages 10, 11, 13 and 66). +TEST(GameText, EncodesAsTheDiscDoes) { + EXPECT_EQ(Encode("Norune Village"), (std::vector{-0x2D2, -0x2B7, -0x2B4, -0x2B1, -0x2B8, -0x2C1, -0xFE, -0x2CA, + -0x2BD, -0x2BA, -0x2BA, -0x2C5, -0x2BF, -0x2C1, -0xFF})); + EXPECT_EQ(Encode("Tanière de la bête"), + (std::vector{-0x2CC, -0x2C5, -0x2B8, -0x2BD, -0x278, -0x2B4, -0x2C1, -0xFE, -0x2C2, -0x2C1, -0xFE, + -0x2BA, -0x2C5, -0xFE, -0x2C4, -0x276, -0x2B2, -0x2C1, -0xFF})); + EXPECT_EQ(Encode("Götterbiest-Höhle"), + (std::vector{-0x2D9, -0x26C, -0x2B2, -0x2B2, -0x2C1, -0x2B4, -0x2C4, -0x2BD, -0x2C1, -0x2B3, -0x2B2, + -0x2A3, -0x2D8, -0x26C, -0x2BE, -0x2BA, -0x2C1, -0xFF})); + EXPECT_EQ(Encode("Più Ricco"), + (std::vector{-0x2D0, -0x2BD, -0x26B, -0xFE, -0x2CE, -0x2BD, -0x2C3, -0x2C3, -0x2B7, -0xFF})); +} + +// MakeMesWinTbl_value's own codes for the characters of a number. +TEST(GameText, NumbersMatchTheGame) { + EXPECT_EQ(GameTextCode(U'+'), -0x2A4); + EXPECT_EQ(GameTextCode(U'-'), -0x2A3); + for (int digit = 0; digit < 10; digit++) { + EXPECT_EQ(GameTextCode(U'0' + digit), -0x291 + digit); + } +} + +TEST(GameText, EveryCharacterRoundTrips) { + int characters = 0; + for (int code = -0x300; code < -0x251; code++) { + const char32_t ch = GameTextChar(static_cast(code)); + if (ch != 0) { + characters++; + EXPECT_EQ(GameTextCode(ch), code) << code; + } + } + EXPECT_EQ(characters, 88 + 49); + // The codes that draw a bare '?' in place of a letter the font lacks. + for (int code : {-0x263, -0x262, -0x261, -0x253, -0x252}) { + EXPECT_EQ(GameTextChar(static_cast(code)), 0U) << code; + } +} + +TEST(GameText, ReplacesWhatTheFontLacks) { + int missing = 0; + EXPECT_EQ(Encode("a~b;\xFF", &missing), (std::vector{-0x2C5, -0x2A7, -0x2C4, -0x2A7, -0x2A7, -0xFF})); + EXPECT_EQ(missing, 3); + EXPECT_EQ(Encode("\xE2\x80\x99\xE2\x80\x9C\xE2\x80\x94", &missing), Encode("'\"-")); + EXPECT_EQ(missing, 0); +} + +TEST(GameText, EscapesRoundTrip) { + const std::vector codes = {-0x2DF, MES_CODE_NEWLINE, -0x2F0, MES_CODE_PAGE, -0x299, -0x298, -0x2DE, MES_CODE_END}; + const std::string text = GameTextDecode(codes.data()); + EXPECT_EQ(text, "A\n{-752}{-253}{{}B"); + EXPECT_EQ(Encode(text), codes); + EXPECT_EQ(Encode("{x}"), Encode("{{x}")); +} + +// The file reads back through the game's own lookup. +TEST(GameText, FileReadsAsAMessageFile) { + GameTextFile file; + file.Set(0x15E, "PC Settings"); + file.Set(3, "Mouse sensitivity\n0.10"); + + ClsMes mes; + mes.SetBuff(file.Data()); + EXPECT_EQ(GameTextDecode(mes.GetTextLineDataTop(0x15E)), "PC Settings"); + EXPECT_EQ(GameTextDecode(mes.GetTextLineDataTop(3)), "Mouse sensitivity\n0.10"); + EXPECT_EQ(mes.GetTextLineDataTop(4), nullptr); +} From 72e49251e8af1d8d14209ca8d70f469c1979c836 Mon Sep 17 00:00:00 2001 From: carrdylan52 Date: Mon, 5 Oct 2026 23:58:23 -0400 Subject: [PATCH 2/3] Keep game text within the message window's and file's fixed sizes SetMesWinTbl now takes nothing past MES_WIN_LINE_MAX laid-out characters, where retail writes on over the window, and GameText::Set reports a text that did not fit (its end never laid out) rather than drawing part of it. DrawMesWin draws a line past the ten line_pos entries in the block layout instead of reading beyond them. GameTextFile::Set refuses an id outside s16 and a message that would start beyond the file's s16 offsets, leaving the file as it was. --- docs/PC.md | 11 ++++++ port/src/clsmes.cpp | 55 ++++++++++++++++++++++++++- port/src/gametext.cpp | 64 ++++++++++++++++++++++---------- port/src/gametext.hpp | 15 ++++++-- port/src/tests/gametext_test.cpp | 38 +++++++++++++++++++ 5 files changed, 158 insertions(+), 25 deletions(-) diff --git a/docs/PC.md b/docs/PC.md index cbc0f5085..f698495e0 100644 --- a/docs/PC.md +++ b/docs/PC.md @@ -803,6 +803,17 @@ text drawn as the menus draw their help line (`InitMenuMesSet`'s the text, `SetColour` a `FontColor`, then `Draw(x, y, alpha)` from a menu's draw function; `Width` gives its width for aligning a value. +Both have retail's fixed sizes. A window lays out at most `MES_WIN_LINE_MAX` +(720) characters, counting the end and what a `{N}` name, value or system +message code expands to; retail's `SetMesWinTbl` writes on past the table, so +the port's (`port/src/clsmes.cpp`) takes no more, and `GameText::Set` gives back +-1, draws nothing and has a width of 0 for a text that did not fit. A line past +the window's ten `line_pos` entries draws in the block layout, as a line +without one does. A message file is s16 throughout: an id is -0x8000 to +0x7FFF, and each message but the last (by id) must start within 0x7FFF codes +of `&buff[1 + count]`; `GameTextFile::Set` gives back -1 and leaves the file +as it was where either would not hold. + ## Arenas `CDataAlloc2<1>::Alloc/Alloc64/Align64` and the carving in diff --git a/port/src/clsmes.cpp b/port/src/clsmes.cpp index 8cdd193b4..ec5aeebf3 100644 --- a/port/src/clsmes.cpp +++ b/port/src/clsmes.cpp @@ -3,6 +3,7 @@ #include #include +#include #include #include "draw2d_port.hpp" @@ -457,6 +458,56 @@ PC_OVERRIDE void ClsMes::MakeFukidashi(sceVif1Packet *packet) { services.set_alpha(nullptr); } +// Retail's, but a full table takes no more: past MES_WIN_LINE_MAX laid-out characters retail writes +// on over the fields that follow it. MakeMesWinTbl then lays out no MES_CODE_END, which tells a +// caller the text did not fit. +PC_OVERRIDE int ClsMes::SetMesWinTbl(int code, int mode, short x, short y) { + if (code >= -0x200 && code <= -0x101) { + if (this->win_line_num > 0) { + this->win_line[this->win_line_num - 1].space += code + 0x200; + } + + return 0; + } + + if (code >= -0x400 && code <= -0x301) { + if (code + 0x400 == 0) { + this->clut_now = this->clut_default; + } else if (code == -0x301) { + this->clut_now = 0xFF; + } else { + this->clut_now = code + 0x401; + } + + return 0; + } + + if (code >= -0xA00 && code <= -0x901) { + this->fukidashi_shape = code + 0xA00; + return 0; + } + + if (this->win_line_num >= MES_WIN_LINE_MAX) { + return 0; + } + + if (mode == 1) { + this->win_line[this->win_line_num].code = code + 0x1000; + this->win_line[this->win_line_num].x = x; + this->win_line[this->win_line_num].y = y; + this->win_line[this->win_line_num].clut = this->clut_now; + this->win_line_num++; + } else { + this->win_line[this->win_line_num].code = code; + this->win_line[this->win_line_num].x = x; + this->win_line[this->win_line_num].y = y; + this->win_line[this->win_line_num].clut = this->clut_now; + this->win_line_num++; + } + + return 1; +} + PC_OVERRIDE void ClsMes::DrawMesWin() { CTexture *texture; int offset_x; @@ -599,7 +650,9 @@ PC_OVERRIDE void ClsMes::DrawMesWin() { int row = this->win_line[index].y / this->char_height; - if (this->line_pos[row].x < 0 || this->line_pos[row].y < 0) { + // A line past the last line_pos entry takes the block layout, as one with no entry does. + if (row >= static_cast(std::size(this->line_pos)) || this->line_pos[row].x < 0 || + this->line_pos[row].y < 0) { int line_x = this->win_line[index].x + this->text_x; int line_y = this->win_line[index].y + this->text_y; int screen_x = offset_x + (line_x + dx); diff --git a/port/src/gametext.cpp b/port/src/gametext.cpp index b067b6719..abb6cb4f9 100644 --- a/port/src/gametext.cpp +++ b/port/src/gametext.cpp @@ -234,9 +234,28 @@ std::string GameTextDecode(const s16 *codes) { } int GameTextFile::Set(int id, std::string_view utf8) { + if (id < INT16_MIN || id > INT16_MAX) { + return -1; + } + std::vector codes; const int missing = GameTextEncode(utf8, codes); + // Offsets grow with the id, so the file fits when the last message's start does. + const auto found = messages_.find(id); + const bool added = found == messages_.end(); + const size_t count = messages_.size() + (added ? 1 : 0); + const size_t total = codes_ - (added ? 0 : found->second.size()) + codes.size(); + size_t last = codes.size(); + + if (!messages_.empty() && messages_.rbegin()->first > id) { + last = messages_.rbegin()->second.size(); + } + if (1 + count + total - last > INT16_MAX) { + return -1; + } + + codes_ = total; messages_[id] = std::move(codes); dirty_ = true; return missing; @@ -244,27 +263,19 @@ int GameTextFile::Set(int id, std::string_view utf8) { short *GameTextFile::Data() { if (dirty_) { - // ClsMes::GetTextLineDataTop finds a message at &buff[1 + count] plus its s16 offset, so - // the messages that would start past that reach are left out. SetBuff takes buff[1] as an - // offset to a text pointer nothing reads. - size_t count = 0; - size_t length = 0; - for (const auto &message : messages_) { - if (1 + messages_.size() + length > INT16_MAX) { - break; - } - length += message.second.size(); - count++; - } + // SetBuff takes buff[1] as an offset to a text pointer nothing reads. + const size_t count = messages_.size(); data_.assign(2 + count * 2, 0); + data_.reserve(data_.size() + codes_); data_[0] = static_cast(count); - auto message = messages_.begin(); - for (size_t i = 0; i < count; i++, message++) { - data_[2 + i * 2] = static_cast(message->first); - data_[3 + i * 2] = static_cast(data_.size() - (1 + count)); - data_.insert(data_.end(), message->second.begin(), message->second.end()); + size_t entry = 0; + for (const auto &[id, codes] : messages_) { + data_[2 + entry * 2] = static_cast(id); + data_[3 + entry * 2] = static_cast(data_.size() - (1 + count)); + data_.insert(data_.end(), codes.begin(), codes.end()); + entry++; } dirty_ = false; @@ -287,10 +298,13 @@ GameText::GameText() : colour_(FONT_COLOR_WHITE) { } int GameText::Set(std::string_view utf8) { - if (utf8 != text_ || mes_.mes_made < 0) { + if (!set_ || utf8 != text_) { + set_ = true; text_ = utf8; missing_ = file_.Set(0, utf8); - Layout(); + if (missing_ >= 0 && !Layout()) { + missing_ = -1; + } } return missing_; @@ -306,7 +320,7 @@ void GameText::SetColour(u32 colour) { } } -void GameText::Layout() { +bool GameText::Layout() { mes_.SetBuff(file_.Data()); if (SystemMes != nullptr) { mes_.SetBuff_system(SystemMes); @@ -315,6 +329,16 @@ void GameText::Layout() { mes_.clut_now = mes_.clut_default; mes_.mes_made = -1; mes_.MakeMesWin(0); + + // A full table takes nothing more (SetMesWinTbl), so a text that did not fit lacks its end. + if (mes_.win_line_num > 0 && mes_.win_line[mes_.win_line_num - 1].code == MES_CODE_END) { + return true; + } + + mes_.mes_made = -1; + mes_.text_len = 0; + mes_.text_width = 0; + return false; } void GameText::Draw(int x, int y, int alpha) { diff --git a/port/src/gametext.hpp b/port/src/gametext.hpp index 47ba29cb2..58a345e39 100644 --- a/port/src/gametext.hpp +++ b/port/src/gametext.hpp @@ -31,10 +31,13 @@ int GameTextEncode(std::string_view utf8, std::vector &out); std::string GameTextDecode(const s16 *codes); // A message file of port text, laid out as ClsMes::SetBuff reads one: the count, then each -// message's number and where its text starts, then the texts. +// message's number and where its text starts, then the texts, all s16. So an id is -0x8000 to +// 0x7FFF, and every message but the last (by id) has to start within 0x7FFF codes of the table's +// middle, &buff[1 + count]: about 32,000 codes of text in all. class GameTextFile { public: - // Sets message id's text; gives back how many characters GameTextEncode replaced. + // Sets message id's text; gives back how many characters GameTextEncode replaced, or -1, + // leaving the file as it was, where the id or the file would not fit. int Set(int id, std::string_view utf8); // The file, for ClsMes::SetBuff; valid until the next Set. @@ -43,6 +46,7 @@ class GameTextFile { private: std::map> messages_; std::vector data_; + size_t codes_ = 0; bool dirty_ = true; }; @@ -55,7 +59,9 @@ class GameText { GameText(const GameText &) = delete; GameText &operator=(const GameText &) = delete; - // Lays the text out again when it changed; gives back how many characters became '?'. + // Lays the text out again when it changed; gives back how many characters became '?', or -1 + // where it does not fit the window's MES_WIN_LINE_MAX laid-out characters (names and values + // that "{N}" codes name count as theirs), and then nothing draws and Width is 0. int Set(std::string_view utf8); // A FontColor, as FontColorTbl holds them; FONT_COLOR_WHITE until set. @@ -73,11 +79,12 @@ class GameText { ClsMes &Mes() { return mes_; } private: - void Layout(); + bool Layout(); ClsMes mes_; GameTextFile file_; std::string text_; u32 colour_; int missing_ = 0; + bool set_ = false; }; diff --git a/port/src/tests/gametext_test.cpp b/port/src/tests/gametext_test.cpp index 77f03b437..c2ed5d7ce 100644 --- a/port/src/tests/gametext_test.cpp +++ b/port/src/tests/gametext_test.cpp @@ -86,3 +86,41 @@ TEST(GameText, FileReadsAsAMessageFile) { EXPECT_EQ(GameTextDecode(mes.GetTextLineDataTop(3)), "Mouse sensitivity\n0.10"); EXPECT_EQ(mes.GetTextLineDataTop(4), nullptr); } + +TEST(GameText, WindowCapacity) { + GameText text; + EXPECT_EQ(text.Set(std::string(MES_WIN_LINE_MAX - 1, 'A')), 0); + EXPECT_EQ(text.Width(), (MES_WIN_LINE_MAX - 1) * 11); + EXPECT_EQ(text.Set(std::string(MES_WIN_LINE_MAX, 'A')), -1); + EXPECT_EQ(text.Width(), 0); + EXPECT_LT(text.Mes().mes_made, 0); + EXPECT_EQ(text.Mes().win_line_num, MES_WIN_LINE_MAX); + + std::string lines = "A"; + for (int line = 1; line < 11; line++) { + lines += "\nA"; + } + EXPECT_EQ(text.Set(lines), 0); + EXPECT_EQ(text.Mes().text_rows, 11); + text.Draw(0, 0); +} + +TEST(GameText, FileLimits) { + GameTextFile file; + EXPECT_EQ(file.Set(-0x8001, "A"), -1); + EXPECT_EQ(file.Set(0x8000, "A"), -1); + EXPECT_EQ(file.Set(-0x8000, "low"), 0); + EXPECT_EQ(file.Set(0x7FFF, "high"), 0); + + // "high" comes last by id and starts 4 + 4 + (n + 1) codes past &buff[1 + count]. + EXPECT_EQ(file.Set(0, std::string(0x7FF7, 'A')), -1); + EXPECT_EQ(file.Data()[0], 2); + EXPECT_EQ(file.Set(0, std::string(0x7FF6, 'A')), 0); + + ClsMes mes; + mes.SetBuff(file.Data()); + EXPECT_EQ(mes.buff[0], 3); + EXPECT_EQ(GameTextDecode(mes.GetTextLineDataTop(-0x8000)), "low"); + EXPECT_EQ(GameTextDecode(mes.GetTextLineDataTop(0x7FFF)), "high"); + EXPECT_EQ(GameTextDecode(mes.GetTextLineDataTop(0)).size(), 0x7FF6U); +} From 2f6515a7a77b45fe9ca5395dfffbd15201bee3a5 Mon Sep 17 00:00:00 2001 From: carrdylan52 Date: Tue, 6 Oct 2026 00:07:44 -0400 Subject: [PATCH 3/3] Report a game text that stops fitting when its colour changes SetColour's new layout can overflow the window where a value code now prints more digits; Set reported the old count and never laid the text out again. Set now retries while nothing is laid out, and SetColour records the failure. Say plainly that every message in a file must start within its s16 offsets. --- docs/PC.md | 6 +++--- port/src/gametext.cpp | 6 +++--- port/src/gametext.hpp | 4 ++-- port/src/tests/gametext_test.cpp | 11 +++++++++++ 4 files changed, 19 insertions(+), 8 deletions(-) diff --git a/docs/PC.md b/docs/PC.md index f698495e0..d9a59508b 100644 --- a/docs/PC.md +++ b/docs/PC.md @@ -810,9 +810,9 @@ the port's (`port/src/clsmes.cpp`) takes no more, and `GameText::Set` gives back -1, draws nothing and has a width of 0 for a text that did not fit. A line past the window's ten `line_pos` entries draws in the block layout, as a line without one does. A message file is s16 throughout: an id is -0x8000 to -0x7FFF, and each message but the last (by id) must start within 0x7FFF codes -of `&buff[1 + count]`; `GameTextFile::Set` gives back -1 and leaves the file -as it was where either would not hold. +0x7FFF. Every message must start within 0x7FFF codes of `&buff[1 + count]`; +the last message by id may extend beyond that range. `GameTextFile::Set` +gives back -1 and leaves the file as it was where either would not hold. ## Arenas diff --git a/port/src/gametext.cpp b/port/src/gametext.cpp index abb6cb4f9..e99cb0d91 100644 --- a/port/src/gametext.cpp +++ b/port/src/gametext.cpp @@ -298,7 +298,7 @@ GameText::GameText() : colour_(FONT_COLOR_WHITE) { } int GameText::Set(std::string_view utf8) { - if (!set_ || utf8 != text_) { + if (!set_ || utf8 != text_ || mes_.mes_made < 0) { set_ = true; text_ = utf8; missing_ = file_.Set(0, utf8); @@ -314,8 +314,8 @@ void GameText::SetColour(u32 colour) { if (colour != colour_) { colour_ = colour; // The colour goes into each laid-out character, so it takes a new layout. - if (mes_.mes_made >= 0) { - Layout(); + if (mes_.mes_made >= 0 && !Layout()) { + missing_ = -1; } } } diff --git a/port/src/gametext.hpp b/port/src/gametext.hpp index 58a345e39..baf5f4b65 100644 --- a/port/src/gametext.hpp +++ b/port/src/gametext.hpp @@ -32,8 +32,8 @@ std::string GameTextDecode(const s16 *codes); // A message file of port text, laid out as ClsMes::SetBuff reads one: the count, then each // message's number and where its text starts, then the texts, all s16. So an id is -0x8000 to -// 0x7FFF, and every message but the last (by id) has to start within 0x7FFF codes of the table's -// middle, &buff[1 + count]: about 32,000 codes of text in all. +// 0x7FFF. Every message must start within 0x7FFF codes of &buff[1 + count]; the last message by +// id may extend beyond that range. class GameTextFile { public: // Sets message id's text; gives back how many characters GameTextEncode replaced, or -1, diff --git a/port/src/tests/gametext_test.cpp b/port/src/tests/gametext_test.cpp index c2ed5d7ce..29cc8dd9e 100644 --- a/port/src/tests/gametext_test.cpp +++ b/port/src/tests/gametext_test.cpp @@ -5,6 +5,7 @@ #include "clsmes.hpp" #include "gametext.hpp" +#include "gameutil.hpp" namespace { @@ -96,6 +97,16 @@ TEST(GameText, WindowCapacity) { EXPECT_LT(text.Mes().mes_made, 0); EXPECT_EQ(text.Mes().win_line_num, MES_WIN_LINE_MAX); + // A value code's digits count too, and a colour change lays the text out again. + const std::string value = std::string(MES_WIN_LINE_MAX - 3, 'A') + "{-1025}"; + EXPECT_EQ(text.Set(value), 0); + text.Mes().value = 1000; + text.SetColour(FONT_COLOR_YELLOW); + EXPECT_EQ(text.Set(value), -1); + text.Mes().value = 1; + EXPECT_EQ(text.Set(value), 0); + EXPECT_GT(text.Width(), 0); + std::string lines = "A"; for (int line = 1; line < 11; line++) { lines += "\nA";