From 74c40577461fde167f8e0670c92a67ab2c586290 Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Tue, 20 Oct 2026 22:35:19 -0700 Subject: [PATCH] Qualify native language sessions and derived file lifecycle --- docs/NATIVE_LOCALIZATION.md | 55 +++++++++++++++++++++ runtime/include/host_file_identity.h | 59 ++++------------------ runtime/include/mod_packages.h | 2 + runtime/include/mod_plugins.h | 4 ++ runtime/include/native_text.h | 5 +- runtime/src/mod_packages.cpp | 18 +++++++ runtime/src/mod_runtime.cpp | 8 ++- runtime/src/native_text.cpp | 12 ++++- runtime/tests/test_mod_media.cpp | 24 +++++++++ runtime/tests/test_mod_runtime.cpp | 73 ++++++++++++++++++++++++++++ runtime/tests/test_native_text.cpp | 5 ++ 11 files changed, 211 insertions(+), 54 deletions(-) create mode 100644 docs/NATIVE_LOCALIZATION.md diff --git a/docs/NATIVE_LOCALIZATION.md b/docs/NATIVE_LOCALIZATION.md new file mode 100644 index 00000000..a004c9c7 --- /dev/null +++ b/docs/NATIVE_LOCALIZATION.md @@ -0,0 +1,55 @@ +# Native text and donor assets + +`runtime/include/native_text.h` provides the reusable text side of a native +translation mod. It complements the existing draw-hook translation support: +adapters can encode plain Unicode back into original guest text and font data, +then let the guest renderer consume it. + +Catalog schema 1 keeps a stable `id`, readable `original`, editable +`translation`, and lowercase `source_sha256` for each entry. The digest identifies +the exact original native record. An empty translation means native fallback. +The adapter validates source digests against its title's records before making +changes. Duplicate IDs, malformed Unicode and missing digests are errors. + +`NativeGlyphMap` maps Unicode scalars to native glyph indices. +`native_text_encode` wraps words and emits adapter-supplied line, page and final +control bits. Unsupported characters and words exceeding the native width fail; +they never silently become spaces. A title owns its dimensions, style bits, +source record inventory, buffer capacity and alignment requirements. + +`native_font_compose` copies a cell in the original indexed 4-bpp font to an +unused cell and composes an acute accent, tilde, diaeresis or inverted mark. +Native palette indices, base shape and baseline are retained. The adapter can +omit top rows of the copied shape to remove i's dot before composing í. Original +cells remain unchanged, and occupied destinations are refused. Other native +font layouts need their own explicit adapter; there is no host-font fallback. + +For asset-backed games, an activation callback can use +`psx_mod_replace_disc_file` to mount a derived file without modifying the owner +disc. The runtime copies its bytes into an immutable Mode-2 Form-1 extent and +updates both endian halves of its ISO directory record. Host file readers and +guest CD reads resolve the same final file. Multiple replacements of the same +file layer in activation order. Private native LBA/size tables remain the +title adapter's responsibility. Use only verified committed resources and +trusted adapter code to derive these bytes; paths, clocks and host fonts must +not affect their content. + +The `psx-mode2` verified resource format retains 2336 bytes per raw Mode-2 +sector, including XA subheaders and audio/video payloads. Its identity differs +from the existing cooked `psx-disc` format. Cooked ISO images cannot satisfy this +contract. `ModMode2Disc` performs bounded ISO9660 lookup and ordinary file reads +against this immutable snapshot; raw streams mount through +`psx_mod_append_disc_extent` and the original CD decoder. + +Register a session-reset callback for title-owned preparation caches. Activation +reconstructs them from the committed plan; reset retires stale data even when +the next session disables the feature. Save-state identity includes verified +catalog/donor content through the ordinary mod plan. Adapters still need title +playback, visual and audio qualification, plus any relevant co-op or netplay +qualification. + +Mega Man X4 is the first asset adapter. Its source keeps archive and message +format knowledge outside the framework. Focused coverage is in +`native_text_test`, `mod_media_test` and `mod_runtime_test`, including malformed +directory records, cross-sector reads, layered replacement, native directory +parity, immutable sources, session cleanup and edited catalog checksums. diff --git a/runtime/include/host_file_identity.h b/runtime/include/host_file_identity.h index 9b190257..e560189d 100644 --- a/runtime/include/host_file_identity.h +++ b/runtime/include/host_file_identity.h @@ -12,58 +12,19 @@ #include #endif namespace PSXRecompV4 { -// A failed identity is never a cache hit. Change time protects restored mtimes. +// A failed identity is never a cache hit; callers must verify content instead. inline bool host_file_identity(const std::filesystem::path& path, std::string& identity) { identity.clear(); - std::ostringstream out; #if defined(_WIN32) - HANDLE file = CreateFileW(path.c_str(), FILE_READ_ATTRIBUTES, - FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, nullptr, - OPEN_EXISTING, FILE_FLAG_BACKUP_SEMANTICS, nullptr); - if (file == INVALID_HANDLE_VALUE) return false; - struct HandleScope { HANDLE value; ~HandleScope() { CloseHandle(value); } } handle{file}; - WCHAR filesystem_name[32]{}; - // NTFS/ReFS expose change time and native file identity. For a remote - // handle also require SMB3+, rather than assuming every NAS is unsupported. - FILE_REMOTE_PROTOCOL_INFO remote{}; - const bool is_remote = GetFileInformationByHandleEx(file, FileRemoteProtocolInfo, - &remote, sizeof remote) != FALSE; - const DWORD protocol_error = is_remote ? ERROR_SUCCESS : GetLastError(); - bool local_handle = false; - if (!is_remote) { - // A failed protocol query proves nothing about locality. Resolve the - // handle itself: mapped drives become UNC paths here and cannot take - // the local fallback when a remote provider omits protocol metadata. - WCHAR final_path[32768]{}; - const DWORD length = GetFinalPathNameByHandleW(file, final_path, 32768, FILE_NAME_NORMALIZED); - if (length >= 7 && length < 32768 && final_path[0] == L'\\' && - final_path[1] == L'\\' && final_path[2] == L'?' && final_path[3] == L'\\' && - final_path[5] == L':' && final_path[6] == L'\\') { - WCHAR drive_root[] = {final_path[4], L':', L'\\', L'\0'}; - const UINT drive_type = GetDriveTypeW(drive_root); - local_handle = drive_type == DRIVE_FIXED || drive_type == DRIVE_REMOVABLE; - } - } - const bool reliable_protocol = is_remote - ? remote.Protocol == 0x00020000 && remote.ProtocolMajorVersion >= 3 // WNNC_NET_LANMAN - : local_handle && (protocol_error == ERROR_INVALID_PARAMETER || protocol_error == ERROR_NOT_SUPPORTED); - const bool reliable_fs = reliable_protocol && - GetVolumeInformationByHandleW(file, nullptr, 0, nullptr, nullptr, nullptr, - filesystem_name, 32) && - (wcscmp(filesystem_name, L"NTFS") == 0 || wcscmp(filesystem_name, L"ReFS") == 0); - BY_HANDLE_FILE_INFORMATION info{}; - FILE_BASIC_INFO basic{}; - const bool ok = reliable_fs && GetFileInformationByHandle(file, &info) && - GetFileInformationByHandleEx(file, FileBasicInfo, &basic, sizeof basic) && - !(info.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) && - (info.nFileIndexHigh != 0 || info.nFileIndexLow != 0) && - info.dwVolumeSerialNumber != 0 && basic.ChangeTime.QuadPart != 0 && - basic.LastWriteTime.QuadPart != 0; - if (!ok) return false; - out << "win1:" << info.dwVolumeSerialNumber << ':' << info.nFileIndexHigh << ':' - << info.nFileIndexLow << ':' << info.nFileSizeHigh << ':' << info.nFileSizeLow - << ':' << basic.LastWriteTime.QuadPart << ':' << basic.ChangeTime.QuadPart; + // NTFS change/write timestamps can collide across immediate rewrites, + // including same-size writes with a restored mtime. A file USN alone also + // cannot qualify persistent receipts: it may be zero or reset with its + // journal, and the query is unavailable over SMB3. Until a journal-bound + // change identity is qualified, retain the portable full-verification path. + (void)path; + return false; #else + std::ostringstream out; struct stat info{}; if (stat(path.c_str(), &info) || !S_ISREG(info.st_mode) || info.st_ino == 0) return false; out << "posix1:" << info.st_dev << ':' << info.st_ino << ':' << info.st_size; @@ -73,9 +34,9 @@ inline bool host_file_identity(const std::filesystem::path& path, std::string& i #else out << ':' << info.st_mtim.tv_sec << ':' << info.st_mtim.tv_nsec << ':' << info.st_ctim.tv_sec << ':' << info.st_ctim.tv_nsec; -#endif #endif identity = out.str(); return true; +#endif } } diff --git a/runtime/include/mod_packages.h b/runtime/include/mod_packages.h index d989083e..b5b0bbf9 100644 --- a/runtime/include/mod_packages.h +++ b/runtime/include/mod_packages.h @@ -581,6 +581,8 @@ private: bool mod_register_builtin_resolver(const std::string& id, ModBuiltinResolver resolver); void mod_clear_builtin_resolvers_for_tests(); bool mod_register_activation_plugin(const std::string& id, void (*callback)(void)); +bool mod_register_session_reset_plugin(const std::string& id, void (*callback)(void)); +void mod_reset_plugin_sessions(); bool mod_register_vblank_plugin(const std::string& id, void (*callback)(void)); bool mod_register_savestate_plugin(const std::string& id, void (*callback)(void)); void mod_invoke_savestate_plugin(const std::string& id); diff --git a/runtime/include/mod_plugins.h b/runtime/include/mod_plugins.h index a5086ddb..9479d611 100644 --- a/runtime/include/mod_plugins.h +++ b/runtime/include/mod_plugins.h @@ -25,6 +25,10 @@ typedef int (*PSXModFunctionFilterCallback)(struct CPUState* cpu, */ int psx_mod_register_activation_plugin(const char* id, PSXModActivationCallback callback); +/* Host-only cleanup before a new plan, including when it omits this plugin. + * Runs with emulation stopped and no current resource context. */ +int psx_mod_register_session_reset_plugin(const char* id, + PSXModActivationCallback callback); int psx_mod_register_vblank_plugin(const char* id, PSXModVBlankCallback callback); /* Runs after successful guest-state restore, before the restored PC resumes. diff --git a/runtime/include/native_text.h b/runtime/include/native_text.h index 5aa0f6ff..5e18a8e2 100644 --- a/runtime/include/native_text.h +++ b/runtime/include/native_text.h @@ -43,6 +43,9 @@ struct NativeFontAtlas { std::vector packed; }; enum class NativeGlyphMark { Acute, Tilde, Diaeresis, Inverted }; +// omit_top_rows is an adapter-provided crop of the copied glyph, for example +// to remove i's dot before composing í. The source atlas cell is preserved. bool native_font_compose(NativeFontAtlas& atlas, uint16_t base, uint16_t destination, - NativeGlyphMark mark, std::string& error); + NativeGlyphMark mark, std::string& error, + uint32_t omit_top_rows=0); } // namespace PSXRecompV4 diff --git a/runtime/src/mod_packages.cpp b/runtime/src/mod_packages.cpp index 80cf98eb..44c4992b 100644 --- a/runtime/src/mod_packages.cpp +++ b/runtime/src/mod_packages.cpp @@ -43,6 +43,7 @@ std::map& media_preparers() { struct RegisteredPlugin { void (*savestate)(void) = nullptr; PSXModActivationCallback activation = nullptr; + PSXModActivationCallback session_reset = nullptr; PSXModVBlankCallback vblank = nullptr; /* Generated-function entry hooks, keyed by guest address. One id may * observe several functions. */ @@ -1491,6 +1492,18 @@ bool mod_register_activation_plugin(const std::string& id, void (*callback)(void return true; } +bool mod_register_session_reset_plugin(const std::string& id, void (*callback)(void)) { + if (!valid_id(id) || !callback) return false; + RegisteredPlugin& plugin = registered_plugins()[id]; + if (plugin.session_reset) return false; + plugin.session_reset = callback; + return true; +} +void mod_reset_plugin_sessions() { + for (const auto& entry : registered_plugins()) + if (entry.second.session_reset) entry.second.session_reset(); +} + bool mod_register_vblank_plugin(const std::string& id, void (*callback)(void)) { if (!valid_id(id) || !callback) return false; RegisteredPlugin& plugin = registered_plugins()[id]; @@ -4199,6 +4212,11 @@ extern "C" int psx_mod_register_activation_plugin( PSXRecompV4::mod_register_activation_plugin(id, callback) ? 1 : 0; } +extern "C" int psx_mod_register_session_reset_plugin( + const char* id, PSXModActivationCallback callback) { + return id && PSXRecompV4::mod_register_session_reset_plugin(id, callback) ? 1 : 0; +} + extern "C" int psx_mod_register_savestate_plugin( const char* id, PSXModActivationCallback callback) { return id && diff --git a/runtime/src/mod_runtime.cpp b/runtime/src/mod_runtime.cpp index faa5cebf..19239770 100644 --- a/runtime/src/mod_runtime.cpp +++ b/runtime/src/mod_runtime.cpp @@ -1578,6 +1578,7 @@ bool mod_runtime_initialize(const std::filesystem::path& root, } bool mod_runtime_clear_for_netplay(std::string* error) { + mod_reset_plugin_sessions(); psx_mod_netplay_set_active(0); (void)psx_mod_netplay_set_options_identity(""); state().ticket_ready = false; @@ -2112,8 +2113,10 @@ bool mod_runtime_commit(const std::filesystem::path& disc_path, std::string* err if (!s.provider_preparing) s.ticket_ready = false; clear_function_entry_hooks(); s.disc_extents.clear(); - s.derived_disc_patches.clear(); - s.derived_disc_storage.clear(); s.audio_tracks.clear(); s.extent_start = 0; + s.derived_disc_patches.clear(); + s.derived_disc_storage.clear(); + s.audio_tracks.clear(); + s.extent_start = 0; s.main_applied = false; s.error.clear(); psx_mod_counter_add("startup.plan_reuse", 1); @@ -2873,6 +2876,7 @@ bool mod_runtime_read_disc_file_sectors(const std::string& path, uint32_t max_by extern "C" void mod_runtime_activate_plugins(void) { using namespace PSXRecompV4; + mod_reset_plugin_sessions(); HostLaunchTimingScope timing(HOST_LAUNCH_PLUGIN_ACTIVATION); RuntimeMods& s = state(); s.ticket_ready = false; diff --git a/runtime/src/native_text.cpp b/runtime/src/native_text.cpp index 494378f8..3df5c052 100644 --- a/runtime/src/native_text.cpp +++ b/runtime/src/native_text.cpp @@ -109,14 +109,14 @@ bool native_text_encode(const std::string& text,const NativeGlyphMap& glyphs, } bool native_font_compose(NativeFontAtlas& a,uint16_t base,uint16_t dest, - NativeGlyphMark mark,std::string& error) { + NativeGlyphMark mark,std::string& error,uint32_t omit_top_rows) { error.clear(); if(!a.width || !a.height || !a.cell_width || !a.cell_height || a.width%a.cell_width || a.height%a.cell_height || a.width%2 || uint64_t(a.width)*a.height!=uint64_t(a.packed.size())*2 || a.cell_width>64 || a.cell_height>64) {error="Invalid native font atlas";return false;} const uint32_t cols=a.width/a.cell_width,cells=cols*(a.height/a.cell_height); - if(base>=cells || dest>=cells || base==dest) {error="Glyph cell outside native atlas";return false;} + if(base>=cells || dest>=cells || base==dest || omit_top_rows>=a.cell_height) {error="Glyph cell outside native atlas";return false;} auto get=[&](unsigned id,unsigned x,unsigned y) { const size_t p=((id/cols)*a.cell_height+y)*a.width+(id%cols)*a.cell_width+x; return uint8_t((a.packed[p/2]>>((p&1)*4))&15); @@ -129,6 +129,14 @@ bool native_font_compose(NativeFontAtlas& a,uint16_t base,uint16_t dest, const auto v=get(base,x,y);cell[y*a.cell_width+x]=v; if(v) {++colors[v];top=std::min(top,y);bottom=std::max(bottom,y);} } + // Adapter-owned metrics can omit the dot of i before adding an accent. + // Only the copied destination shape changes; source pixels stay intact. + if(omit_top_rows) { + std::fill_n(cell.begin(),omit_top_rows*a.cell_width,0); + top=a.cell_height;bottom=0; + for(unsigned y=omit_top_rows;y mode2_files(24*2336); + auto dual=[&](size_t at,uint32_t value) { + for(unsigned i=0;i<4;++i) {mode2_files[at+i]=uint8_t(value>>(i*8));mode2_files[at+7-i]=uint8_t(value>>(i*8));} + }; + const size_t volume=16*2336+8,dir=20*2336+8; + mode2_files[volume]=1;std::copy_n("CD001",5,mode2_files.begin()+volume+1);mode2_files[volume+6]=1; + dual(volume+158,20);dual(volume+166,2048); + mode2_files[dir]=42;dual(dir+2,21);dual(dir+10,3000);mode2_files[dir+32]=8; + std::copy_n("TEXT.BIN",8,mode2_files.begin()+dir+33); + for(unsigned i=0;i<3000;++i)mode2_files[(21+i/2048)*2336+8+i%2048]=uint8_t(i*7); + ModMode2Disc native(mode2_files.data(),mode2_files.size()); + uint32_t found_lba=0,found_size=0;std::vector found; + check(native.find("text.bin",found_lba,found_size) && found_lba==21 && found_size==3000 && + native.read("TEXT.BIN",found) && found[2049]==uint8_t(2049*7),"Mode-2 ISO file lookup and cross-sector read"); + check(!native.find("../TEXT.BIN",found_lba,found_size),"Mode-2 lookup rejects parent traversal"); + mode2_files[dir]=1; + check(!native.find("TEXT.BIN",found_lba,found_size),"short directory record rejected before name-length access"); + mode2_files[dir]=42;mode2_files[dir+9]^=1; + check(!native.find("TEXT.BIN",found_lba,found_size),"directory dual-endian fields must agree"); + mode2_files[dir+9]^=1;dual(dir+2,24); + check(!native.find("TEXT.BIN",found_lba,found_size),"donor file extent cannot escape snapshot"); + dual(dir+2,21);mode2_files[21*2336+2]=0x20; + check(!native.read("TEXT.BIN",found) && found.empty(),"ordinary file reads cannot consume Form-2 XA payload"); for (const char* file : {"rom.z64", "disc.iso", "disc.bin", "disc.cue", "audio.bin"}) fs::remove(root / file); fs::remove(root); diff --git a/runtime/tests/test_mod_runtime.cpp b/runtime/tests/test_mod_runtime.cpp index a2c8ed5a..9142ccbe 100644 --- a/runtime/tests/test_mod_runtime.cpp +++ b/runtime/tests/test_mod_runtime.cpp @@ -301,6 +301,22 @@ static void test_media_plugin(void) { } +static int derived_reset_calls; +static uint32_t derived_first, derived_second; +static void test_derived_reset(void) { ++derived_reset_calls; } +static void test_derived_plugin(void) { + uint32_t size=0; + if(!psx_mod_read_disc_file("S0/LEVEL.NSF",nullptr,0,&size) || size!=3000) ++failures; + const uint8_t* catalog=nullptr;uint64_t length=0; + if(!psx_mod_current_resource_bytes("catalog",&catalog,&length) || length!=3 || catalog[0]!='e') ++failures; + std::vector first(5000,0x36),second(7000,0x79); + if(!psx_mod_replace_disc_file("S0/LEVEL.NSF",first.data(),uint32_t(first.size()),&derived_first)) ++failures; + if(!psx_mod_replace_disc_file("S0/LEVEL.NSF",second.data(),uint32_t(second.size()),&derived_second)) ++failures; + uint32_t invalid=99; + if(psx_mod_replace_disc_file("S0",first.data(),uint32_t(first.size()),&invalid) || invalid) ++failures; + if(psx_mod_replace_disc_file("S0/MISSING",first.data(),uint32_t(first.size()),&invalid) || invalid) ++failures; +} + extern "C" { void* iso_open(const char*); void iso_close(void*); @@ -1199,6 +1215,63 @@ int main(int argc, char** argv) { check(PSXRecompV4::mod_runtime_commit(raw_path,&error),"reader mount raw disc"); check(psx_mod_read_disc_file("S0/LEVEL.NSF",result.data(),(uint32_t)result.size(),&bytes) && std::equal(result.begin(),result.end(),iso.begin()+22*2048),"raw and ISO reads identical"); + /* Derived native files layer without changing owner media. The host and + * emulated drive must agree on the final ISO directory and file bytes. */ + { + const auto derived_root=root/"derived-native-file"; + const std::vector bundled_catalog{'e','s','!'}; + write_bytes(derived_root/"packages/derived.test/1.0.0/catalog/es.json",bundled_catalog); + write_text(derived_root/"packages/derived.test/1.0.0/manifest.toml", + "format_version = 9\nid = \"derived.test\"\nversion = \"1.0.0\"\nname = \"Derived\"\n" + "[[target]]\ngame_id = \"READER\"\n[[feature]]\nid = \"active\"\nname = \"Active\"\ndefault_enabled = true\n" + "[[resource]]\nfeature = \"active\"\nid = \"catalog\"\nlabel = \"Catalog\"\nformat = \"file\"\nrequired = true\n" + "default = \"catalog/es.json\"\nsize = 3\nsha256 = \""+sha256_hex(bundled_catalog)+"\"\n" + "[[plugin]]\nfeature = \"active\"\nid = \"derived.test.plugin\"\n"); + check(psx_mod_register_activation_plugin("derived.test.plugin",test_derived_plugin),"register derived file preparation"); + check(psx_mod_register_session_reset_plugin("derived.test.plugin",test_derived_reset),"register session retirement"); + check(PSXRecompV4::mod_runtime_initialize(derived_root,"READER",0,{},&error) && + PSXRecompV4::mod_runtime_commit(raw_path,&error),"verified bundled catalog commits"); + const auto resets=derived_reset_calls; + mod_runtime_activate_plugins(); + check(derived_reset_calls==resets+1,"retire previous plugin session before preparing assets"); + check(derived_first==24 && derived_second==27,"layered native files append stable extents"); + std::vector derived(7000); + check(psx_mod_read_disc_file("S0/LEVEL.NSF",derived.data(),uint32_t(derived.size()),&bytes) && + bytes==7000 && std::all_of(derived.begin(),derived.end(),[](auto b){return b==0x79;}),"host sees final derived file before drive activation"); + uint32_t refused=99; + check(!psx_mod_replace_disc_file("S0/LEVEL.NSF",derived.data(),7000,&refused) && !refused,"replacement is activation only"); + mod_runtime_enable_disc_patches(); + void* derived_mount=iso_open(raw_path.string().c_str()); + std::array derived_sector{}; + check(derived_mount && iso_sector_count(derived_mount)==31,"drive includes both derived layers"); + check(iso_read_sector(derived_mount,21,derived_sector.data(),derived_sector.size()) && + derived_sector[2]==27 && derived_sector[9]==27 && derived_sector[10]==0x58 && derived_sector[11]==0x1b && + derived_sector[16]==0x1b && derived_sector[17]==0x58,"guest ISO record has final little and big endian extent and size"); + check(iso_read_raw_sector(derived_mount,27,derived_sector.data(),derived_sector.size()) && + derived_sector[15]==2 && derived_sector[18]==8 && derived_sector[22]==8 && derived_sector[24]==0x79, + "guest reads native Mode-2 Form-1 derived data"); + check(iso_read_sector(derived_mount,30,derived_sector.data(),derived_sector.size()) && + derived_sector[855]==0x79 && derived_sector[856]==0,"derived final sector is padded"); + mod_runtime_activate_plugins(); + check(derived_first==24 && derived_second==27 && iso_sector_count(derived_mount)==31,"reactivation retires old derived state"); + check(PSXRecompV4::mod_runtime_clear_for_netplay(&error),"clear derived native files"); + check(iso_sector_count(derived_mount)==24 && psx_mod_read_disc_file("S0/LEVEL.NSF",nullptr,0,&bytes) && bytes==3000, + "clearing restores original host and guest extents"); + iso_close(derived_mount);PSXRecompV4::mod_runtime_end_netplay(); + std::ifstream unchanged(raw_path,std::ios::binary); + check(std::vector(std::istreambuf_iterator(unchanged),{})==raw_iso,"owner disc remains byte identical"); + // scan() migrates legacy packages/ into installed/. Mutate the active + // payload, rather than recreating the retired migration source. + write_bytes(derived_root/"installed/derived.test/1.0.0/catalog/es.json",{'b','a','d'}); + write_text(derived_root/"state.toml","format_version = 2\n[[feature]]\npackage_id = \"derived.test\"\nid = \"active\"\nenabled = true\n"); + PSXRecompV4::ModPackageManager selected_derived;selected_derived.set_root(derived_root); + check(selected_derived.scan(&error) && selected_derived.load_state(&error) && + selected_derived.feature_enabled("derived.test","active"),"tampered catalog feature remains selected for verification"); + const auto tampered=selected_derived.resolve("READER",{},{}); + check(!tampered.ok,"tampered bundled catalog fails resolution"); + check(PSXRecompV4::mod_runtime_initialize(derived_root,"READER",0,{},&error),"reload selected bundled catalog"); + check(!PSXRecompV4::mod_runtime_commit(raw_path,&error),"modified bundled catalog rejected by SHA-256"); + } /* Verified bytes are scoped to the committed feature, and do not depend * on reopening an owner file after launch. No launcher is needed here. */ const auto media_root = root / "verified-media"; diff --git a/runtime/tests/test_native_text.cpp b/runtime/tests/test_native_text.cpp index 39813bf1..9d5fd3b3 100644 --- a/runtime/tests/test_native_text.cpp +++ b/runtime/tests/test_native_text.cpp @@ -39,6 +39,11 @@ int main() { check(atlas.packed[4*16+10]==0x33 && atlas.packed[5*16+10]==0x23,"base glyph palette and outline copied exactly"); const auto composed=atlas.packed; check(!native_font_compose(atlas,0,1,NativeGlyphMark::Tilde,error) && atlas.packed==composed,"occupied glyph cannot be overwritten"); + NativeFontAtlas dotted;dotted.width=32;dotted.height=16;dotted.packed.resize(256); + dotted.packed[2*16+2]=0x33;dotted.packed[7*16+2]=0x23; + check(native_font_compose(dotted,0,1,NativeGlyphMark::Acute,error,4),"native i dot can be omitted before accenting"); + check(dotted.packed[2*16+2]==0x33 && dotted.packed[2*16+10]==0 && dotted.packed[7*16+10]==0x23, + "dot removal preserves source and destination stem"); std::cout<<(failures?"FAIL":"PASS")<<": native translation catalog, Unicode, layout and font composition\n"; return failures?1:0; }