From 1bc16a725c0031d5f4553b36106a918771b65900 Mon Sep 17 00:00:00 2001 From: Thomas Cantwell Date: Tue, 6 Oct 2026 20:00:33 -0700 Subject: [PATCH 1/2] Give the PAL disc's English quick-change pack the icon sheet in the HUD's palette The dungeon copies weapon icons from a wepicon sheet into the HUD's itempack by palette index, so the two have to share a palette. On the PAL disc, English's quickchr.pac and itemlst.img carry the American wepicon, whose palette shares 5 of 256 colours with the English itempack, and the icon a character change copies to the HUD comes out in the wrong colours. The English dungeon-entry packs hold the same sheet in itempack's palette, which is the one the floor's own copy reads. Extraction now writes normalized copies of those two files with that sheet in the other's place, for any language whose sheet does not share its itempack's palette; on the July 12 PAL disc that is English alone, and NTSC needs nothing. --- docs/PC.md | 6 +- port/src/tests/data_extract_test.cpp | 72 ++++++++++++++ tools/dcdata/dcdata.hpp | 137 +++++++++++++++++++++++++++ 3 files changed, 214 insertions(+), 1 deletion(-) diff --git a/docs/PC.md b/docs/PC.md index cc2b88f08..754b5177c 100644 --- a/docs/PC.md +++ b/docs/PC.md @@ -45,7 +45,11 @@ port/build/pc/darkcloud --data data --save save ``` The extractor accepts a PAL or NTSC disc image. For NTSC data it supplies the -localized filenames and pack members expected by the shared game code. Original +localized filenames and pack members expected by the shared game code. For the +PAL disc's English data it gives the quick-change pack and the item list the +weapon icon sheet in the HUD's palette, which the language's dungeon-entry packs +carry: theirs keeps the American palette, and the icon a character change copies +to the HUD comes out in the wrong colours. Original disc files remain in the extracted tree for verification against `DATA.HD2`; normalized pack copies live under `data/normalized` and are selected by the port's file index. Extract again after updating the port to refresh them. diff --git a/port/src/tests/data_extract_test.cpp b/port/src/tests/data_extract_test.cpp index fc0832091..ad3ccf038 100644 --- a/port/src/tests/data_extract_test.cpp +++ b/port/src/tests/data_extract_test.cpp @@ -1,6 +1,7 @@ #include #include +#include #include "data_fixture.hpp" @@ -183,6 +184,77 @@ TEST(DataExtract, NormalizesNtscAssets) { fs::remove_all(dir); } +// An IM2 bank of 16 by 16 eight-bit pictures, each with its own palette and pixel fill. +static Bytes MakeIconBank(const std::vector> &pictures) { + constexpr std::size_t picture = 16 + 48 + 256 + 1024; + Bytes bank(16 + pictures.size() * 48, 0); + std::memcpy(bank.data(), "IM2", 3); + Put32(bank, 4, static_cast(pictures.size())); + for (std::size_t i = 0; i < pictures.size(); i++) { + const auto &[name, colour, pixel] = pictures[i]; + std::memcpy(bank.data() + 16 + i * 48, name.c_str(), name.size()); + Put32(bank, 16 + i * 48 + 32, static_cast(bank.size())); + Bytes tim(picture, 0); + std::memcpy(tim.data(), "TIM2", 4); + Put32(tim, 16, 0x30030); // the wrong total the game's images carry + Put32(tim, 16 + 4, 1024); + Put32(tim, 16 + 8, 256); + tim[16 + 12] = 0x30; + tim[16 + 15] = 0x01; + tim[16 + 19] = 5; + tim[16 + 20] = 16; + tim[16 + 22] = 16; + std::fill(tim.begin() + 64, tim.begin() + 64 + 256, pixel); + for (int c = 0; c < 256; c++) { + Put32(tim, 64 + 256 + c * 4, colour + c); + } + bank.insert(bank.end(), tim.begin(), tim.end()); + } + return bank; +} + +TEST(DataExtract, NormalizesIconSheets) { + fs::path dir = TempDir("normalize_icons"); + Bytes american = MakeIconBank({ + {"quickchara", 0x300, 3}, + {"wepicon", 0x200, 4} + }); + Bytes english = MakeIconBank({ + {"wepicon", 0x100, 5} + }); + Disc disc = MakeDisc({ + {"commenu/a_eng/itempack.img", MakeIconBank({{"itempack", 0x100, 1}}) }, + {"commenu/a_eng/quickchr.pac", MakePack({{"qchr.mes", Pattern(8, 2)}, {"quickchr.img", american}})}, + {"commenu/a_eng/itemlst.img", MakeIconBank({{"wepicon", 0x200, 4}, {"itemicon", 0x200, 7}}) }, + {"commenu/a_eng/dunenter/dunenter2.pak", MakePack({{"dunenter.img", english}}) }, + {"commenu/a_fre/itempack.img", MakeIconBank({{"itempack", 0x200, 6}}) }, + {"commenu/a_fre/quickchr.pac", MakePack({{"quickchr.img", american}}) }, + {"commenu/a_fre/dunenter/dunenter2.pak", MakePack({{"dunenter.img", english}}) }, + }); + fs::path out = dir / "data"; + dcdata::Extract(dcdata::OpenArchive(WriteStandardIso(dir, disc)), out, nullptr); + + // English: both American sheets take the English one; the rest of each file is as it was. + dcdata::Im2Picture donor = dcdata::FindIm2Picture(english, "wepicon"); + auto holds_donor = [&](const Bytes &bank) { + dcdata::Im2Picture icons = dcdata::FindIm2Picture(bank, "wepicon"); + return icons.size == donor.size && + std::equal(english.begin() + donor.offset, english.begin() + donor.offset + donor.size, bank.begin() + icons.offset); + }; + auto quick = dcdata::ReadPack(ReadBytes(out / "normalized/commenu/a_eng/quickchr.pac")); + ASSERT_EQ(quick.size(), 2); + EXPECT_EQ(quick[0].data, Pattern(8, 2)); + EXPECT_TRUE(holds_donor(quick[1].data)); + dcdata::Im2Picture portraits = dcdata::FindIm2Picture(quick[1].data, "quickchara"); + ASSERT_NE(portraits.size, 0); + EXPECT_TRUE(std::equal(american.begin() + portraits.offset, american.begin() + portraits.offset + portraits.size, + quick[1].data.begin() + portraits.offset)); + EXPECT_TRUE(holds_donor(ReadBytes(out / "normalized/commenu/a_eng/itemlst.img"))); + // French: its sheet already shares itempack's palette. + EXPECT_FALSE(fs::exists(out / "normalized/commenu/a_fre/quickchr.pac")); + fs::remove_all(dir); +} + TEST(DataExtract, FromDirectory) { fs::path dir = TempDir("extract_dir"); Disc disc = StandardDisc(); diff --git a/tools/dcdata/dcdata.hpp b/tools/dcdata/dcdata.hpp index bd38177ea..548152c55 100644 --- a/tools/dcdata/dcdata.hpp +++ b/tools/dcdata/dcdata.hpp @@ -506,7 +506,144 @@ inline bool AddPackAlias(std::vector &members, std::string_view name return false; } +// Where a picture named `name` lies in an IM2 bank: the offset of its TIM2 file and its length. +struct Im2Picture { + std::size_t offset = 0; + std::size_t size = 0; +}; + +inline Im2Picture FindIm2Picture(std::span bank, std::string_view name) { + if (bank.size() < 16 || std::memcmp(bank.data(), "IM2", 3) != 0) { + return {}; + } + std::uint32_t count = Le32(bank.data() + 4); + if (16 + std::uint64_t(count) * 48 > bank.size()) { + return {}; + } + for (std::uint32_t i = 0; i < count; i++) { + const unsigned char *entry = bank.data() + 16 + i * 48; + std::size_t length = strnlen(reinterpret_cast(entry), 32); + if (std::string_view(reinterpret_cast(entry), length) != name) { + continue; + } + std::size_t offset = Le32(entry + 32); + if (offset + 32 > bank.size() || std::memcmp(bank.data() + offset, "TIM2", 4) != 0) { + return {}; + } + // The picture header's total size is not reliable in the game's images; its parts are. + const unsigned char *picture = bank.data() + offset + 16; + std::size_t size = 16 + std::size_t(picture[12] | picture[13] << 8) + Le32(picture + 8) + Le32(picture + 4); + if (size > bank.size() - offset) { + return {}; + } + return {offset, size}; + } + return {}; +} + +// The colours of an 8-bit TIM2 picture's 256-entry CLUT as RGB, or none. +inline std::vector Tim2Colours(std::span tim) { + if (tim.size() < 48 || tim[16 + 0x13] != 5 || (tim[16 + 0x0E] | tim[16 + 0x0F] << 8) != 256) { + return {}; + } + std::size_t clut = 16 + (tim[16 + 0x0C] | tim[16 + 0x0D] << 8) + Le32(tim.data() + 16 + 8); + if (clut + 1024 > tim.size()) { + return {}; + } + std::vector colours(256); + for (int i = 0; i < 256; i++) { + colours[i] = Le32(tim.data() + clut + i * 4) & 0xFFFFFF; + } + return colours; +} + +inline int SharedColours(const std::vector &a, const std::vector &b) { + int shared = 0; + for (std::size_t i = 0; i < a.size() && i < b.size(); i++) { + shared += a[i] == b[i]; + } + return shared; +} + +// The dungeon copies weapon icons from a wepicon sheet into the HUD's itempack by palette index, so +// the two have to share a palette. The PAL disc's English quick-change pack and item list carry the +// American wepicon, whose palette is not the English itempack's, and the icon a character change +// copies comes out in the wrong colours. The language's dungeon-entry packs hold the same sheet in +// itempack's palette, and it takes the other's place. +inline void NormalizeIconSheets(const fs::path &out, const std::vector &records) { + // A sheet in another palette shares a handful of its colours with itempack's. + constexpr int kSharedPalette = 250; + constexpr std::string_view prefix = "commenu/"; + constexpr std::string_view suffix = "/itempack.img"; + for (const Record &record : records) { + if (!record.path.starts_with(prefix) || !record.path.ends_with(suffix)) { + continue; + } + std::string language = record.path.substr(prefix.size(), record.path.size() - prefix.size() - suffix.size()); + if (language.empty() || language.find('/') != std::string::npos) { + continue; + } + std::vector itempack = ReadFile(out / record.path); + Im2Picture hud = FindIm2Picture(itempack, "itempack"); + if (hud.size == 0) { + continue; + } + std::vector hud_colours = Tim2Colours(std::span(itempack).subspan(hud.offset, hud.size)); + + std::vector donor; + std::string entry_packs = std::format("commenu/{}/dunenter/", language); + for (const Record &other : records) { + if (!donor.empty() || !other.path.starts_with(entry_packs) || !other.path.ends_with(".pak")) { + continue; + } + for (const PackMember &member : ReadPack(ReadFile(out / other.path))) { + Im2Picture icons = FindIm2Picture(member.data, "wepicon"); + if (icons.size != 0 && + SharedColours(Tim2Colours(std::span(member.data).subspan(icons.offset, icons.size)), hud_colours) >= kSharedPalette) { + donor.assign(member.data.begin() + icons.offset, member.data.begin() + icons.offset + icons.size); + break; + } + } + } + if (donor.empty()) { + continue; + } + + // Whether the bank's wepicon was in another palette and the donor took its place. + auto replace = [&](std::vector &bank) { + Im2Picture icons = FindIm2Picture(bank, "wepicon"); + if (icons.size != donor.size() || + SharedColours(Tim2Colours(std::span(bank).subspan(icons.offset, icons.size)), hud_colours) >= kSharedPalette) { + return false; + } + std::copy(donor.begin(), donor.end(), bank.begin() + icons.offset); + return true; + }; + std::string list = std::format("commenu/{}/itemlst.img", language); + if (fs::is_regular_file(out / list)) { + std::vector bank = ReadFile(out / list); + if (replace(bank)) { + WriteFile(out / "normalized" / list, bank); + } + } + std::string quick = std::format("commenu/{}/quickchr.pac", language); + if (fs::is_regular_file(out / quick)) { + std::vector members = ReadPack(ReadFile(out / quick)); + bool changed = false; + for (PackMember &member : members) { + if (EqualsFolded(member.name, "quickchr.img")) { + changed |= replace(member.data); + } + } + if (changed) { + WriteFile(out / "normalized" / quick, WritePack(members)); + } + } + } +} + inline void Normalize(const fs::path &out, const std::vector &records) { + NormalizeIconSheets(out, records); std::unordered_set paths; for (const Record &record : records) { paths.insert(record.path); From dbdb9ef088b3757d54b2a0e0daf4213ef1a93aa3 Mon Sep 17 00:00:00 2001 From: Thomas Cantwell Date: Tue, 6 Oct 2026 23:03:01 -0700 Subject: [PATCH 2/2] Give every icon sheet of a language the HUD's palette, not only two The HUD's weapon icon is copied by palette index from whichever wepicon sheet is loaded when it is redrawn: a dungeon-entry pack's on a new floor, then a menu's once the Weapon menu, the quick-change ring or an item-list reload closes. Only the quick-change pack and the item list were fixed, but on the PAL disc every English sheet apart from the dungeon-entry ones is in the American palette: the per-character menu sheets (kge*2.img), wepicon.img, charatex.img, wepchara.img and the shop and fishing menus. Closing the Weapon menu turned the icon teal on retail PAL as well (seen in PCSX2). Extraction now checks every image bank and pack in each language's commenu folder, skipping leftovers the game never loads (_x.img, x.old). On the July 12 PAL disc that is 13 English files, plus the fishing and shop menus of French, German, Italian and Spanish, which only the town draws but which break the same rule. --- docs/PC.md | 10 +++--- port/src/tests/data_extract_test.cpp | 9 ++++- tools/dcdata/dcdata.hpp | 51 +++++++++++++++++----------- 3 files changed, 46 insertions(+), 24 deletions(-) diff --git a/docs/PC.md b/docs/PC.md index 754b5177c..83770b0bf 100644 --- a/docs/PC.md +++ b/docs/PC.md @@ -46,10 +46,12 @@ port/build/pc/darkcloud --data data --save save The extractor accepts a PAL or NTSC disc image. For NTSC data it supplies the localized filenames and pack members expected by the shared game code. For the -PAL disc's English data it gives the quick-change pack and the item list the -weapon icon sheet in the HUD's palette, which the language's dungeon-entry packs -carry: theirs keeps the American palette, and the icon a character change copies -to the HUD comes out in the wrong colours. Original +PAL disc's English data it gives every weapon icon sheet the one in the HUD's +palette, which the language's dungeon-entry packs carry. The game copies the +HUD's weapon icon by palette index from whichever sheet a menu last loaded, and +English's others keep the American palette, so on closing the main menu or the +quick-change ring the icon came out in the wrong colours. The other languages' +fishing and shop sheets get it too. Original disc files remain in the extracted tree for verification against `DATA.HD2`; normalized pack copies live under `data/normalized` and are selected by the port's file index. Extract again after updating the port to refresh them. diff --git a/port/src/tests/data_extract_test.cpp b/port/src/tests/data_extract_test.cpp index ad3ccf038..50b5457b0 100644 --- a/port/src/tests/data_extract_test.cpp +++ b/port/src/tests/data_extract_test.cpp @@ -227,6 +227,9 @@ TEST(DataExtract, NormalizesIconSheets) { {"commenu/a_eng/quickchr.pac", MakePack({{"qchr.mes", Pattern(8, 2)}, {"quickchr.img", american}})}, {"commenu/a_eng/itemlst.img", MakeIconBank({{"wepicon", 0x200, 4}, {"itemicon", 0x200, 7}}) }, {"commenu/a_eng/dunenter/dunenter2.pak", MakePack({{"dunenter.img", english}}) }, + {"commenu/a_eng/kgetoan2.img", MakeIconBank({{"wepicon", 0x200, 4}}) }, + {"commenu/a_eng/_charatex.img", MakeIconBank({{"wepicon", 0x200, 4}}) }, + {"commenu/a_eng/manual/m10.pac", Pattern(80, 9) }, {"commenu/a_fre/itempack.img", MakeIconBank({{"itempack", 0x200, 6}}) }, {"commenu/a_fre/quickchr.pac", MakePack({{"quickchr.img", american}}) }, {"commenu/a_fre/dunenter/dunenter2.pak", MakePack({{"dunenter.img", english}}) }, @@ -234,7 +237,7 @@ TEST(DataExtract, NormalizesIconSheets) { fs::path out = dir / "data"; dcdata::Extract(dcdata::OpenArchive(WriteStandardIso(dir, disc)), out, nullptr); - // English: both American sheets take the English one; the rest of each file is as it was. + // English: the American sheets take the English one; the rest of each file is as it was. dcdata::Im2Picture donor = dcdata::FindIm2Picture(english, "wepicon"); auto holds_donor = [&](const Bytes &bank) { dcdata::Im2Picture icons = dcdata::FindIm2Picture(bank, "wepicon"); @@ -250,6 +253,10 @@ TEST(DataExtract, NormalizesIconSheets) { EXPECT_TRUE(std::equal(american.begin() + portraits.offset, american.begin() + portraits.offset + portraits.size, quick[1].data.begin() + portraits.offset)); EXPECT_TRUE(holds_donor(ReadBytes(out / "normalized/commenu/a_eng/itemlst.img"))); + // The main menu's sheet too, but not a leftover the game never loads, and a .pac without a pack. + EXPECT_TRUE(holds_donor(ReadBytes(out / "normalized/commenu/a_eng/kgetoan2.img"))); + EXPECT_FALSE(fs::exists(out / "normalized/commenu/a_eng/_charatex.img")); + EXPECT_FALSE(fs::exists(out / "normalized/commenu/a_eng/manual/m10.pac")); // French: its sheet already shares itempack's palette. EXPECT_FALSE(fs::exists(out / "normalized/commenu/a_fre/quickchr.pac")); fs::remove_all(dir); diff --git a/tools/dcdata/dcdata.hpp b/tools/dcdata/dcdata.hpp index 548152c55..f84236af6 100644 --- a/tools/dcdata/dcdata.hpp +++ b/tools/dcdata/dcdata.hpp @@ -565,11 +565,13 @@ inline int SharedColours(const std::vector &a, const std::vector< return shared; } -// The dungeon copies weapon icons from a wepicon sheet into the HUD's itempack by palette index, so -// the two have to share a palette. The PAL disc's English quick-change pack and item list carry the -// American wepicon, whose palette is not the English itempack's, and the icon a character change -// copies comes out in the wrong colours. The language's dungeon-entry packs hold the same sheet in -// itempack's palette, and it takes the other's place. +// The dungeon copies weapon icons by palette index from whichever wepicon sheet is loaded into the +// HUD's itempack: a dungeon-entry pack's on a new floor, then a menu's once the main menu or the +// quick-change ring closes or a script reloads the item list. So every wepicon sheet of a language +// has to share itempack's palette, as the American ones do. On the PAL disc only English's +// dungeon-entry sheets do, and the HUD icon comes out in the wrong colours after any of those; the +// other languages' fishing and shop sheets are off as well, though only the town shows them. A +// sheet in another palette takes the dungeon-entry sheet's place: the same icons in itempack's. inline void NormalizeIconSheets(const fs::path &out, const std::vector &records) { // A sheet in another palette shares a handful of its colours with itempack's. constexpr int kSharedPalette = 250; @@ -619,24 +621,35 @@ inline void NormalizeIconSheets(const fs::path &out, const std::vector & std::copy(donor.begin(), donor.end(), bank.begin() + icons.offset); return true; }; - std::string list = std::format("commenu/{}/itemlst.img", language); - if (fs::is_regular_file(out / list)) { - std::vector bank = ReadFile(out / list); - if (replace(bank)) { - WriteFile(out / "normalized" / list, bank); + // Every image bank and pack of the language, but the leftovers the game never names (_x, x.old). + std::string folder = std::format("commenu/{}/", language); + for (const Record &file : records) { + std::string_view path = file.path; + std::string_view name = path.substr(path.rfind('/') + 1); + bool bank = path.ends_with(".img"); + if (!path.starts_with(folder) || name.starts_with('_') || + !(bank || path.ends_with(".pak") || path.ends_with(".pac"))) { + continue; } - } - std::string quick = std::format("commenu/{}/quickchr.pac", language); - if (fs::is_regular_file(out / quick)) { - std::vector members = ReadPack(ReadFile(out / quick)); - bool changed = false; - for (PackMember &member : members) { - if (EqualsFolded(member.name, "quickchr.img")) { - changed |= replace(member.data); + std::vector data = ReadFile(out / file.path); + if (bank) { + if (replace(data)) { + WriteFile(out / "normalized" / file.path, data); } + continue; + } + std::vector members; + try { + members = ReadPack(data); + } catch (const Error &) { + continue; // a .pac that holds no pack + } + bool changed = false; + for (PackMember &member : members) { + changed |= replace(member.data); } if (changed) { - WriteFile(out / "normalized" / quick, WritePack(members)); + WriteFile(out / "normalized" / file.path, WritePack(members)); } } }