diff --git a/audit/history.jsonl b/audit/history.jsonl index 4ee0c499..2e9f1a8e 100644 --- a/audit/history.jsonl +++ b/audit/history.jsonl @@ -487,3 +487,4 @@ {"time":"2026-09-22T12:00:00Z","event":"dead_code_removal","deleted_system_files":106,"deleted_tests":106,"lines_removed":36221,"manifest_refs_removed":76,"contracts_touched":18,"reason":"project/*_system.gd reachable only from their own test_*_parity.gd; not implementations of 40250 behavior (SKILL.md: unreachable code is not evidence)","tests":["full headless Godot suite: 192 PASS, 7 FAIL and 5 render tests hanging in headless, all identical on the pre-deletion tree"]} {"time":"2026-09-22T13:00:00Z","event":"port_map_rebaseline","unit":"UserInterface/PythonPlayerEventHandler.cpp","reset_to_todo":["CPythonPlayerEventHandler::GetSingleton","CPythonPlayerEventHandler::~CPythonPlayerEventHandler","CPythonPlayerEventHandler::OnMoving","CPythonPlayerEventHandler::OnMove","CPythonPlayerEventHandler::OnStop","CPythonPlayerEventHandler::CPythonPlayerEventHandler"],"reason":"new mirror architecture owns the CPythonPlayerEventHandler singleton in native code; prior GDScript counterparts are migration sources, not final mirror implementations","tests":["port_map.py check"]} {"time":"2026-09-22T15:00:00Z","event":"port_map_rebaseline","scope":"all units","totals":{"logic":3343,"python":5175,"platform":2091,"all":10609,"done":0,"divergent":1},"previous":{"logic":3285,"python":4890,"platform":2229,"all":10404},"changes":["cpp_functions: strip comments, accept indented qualified definitions inside namespace blocks, long parameter/initializer lists (40-line window), all-caps qualified ctors, skip constructor initializer entries","EterBase split by unit: tea/lzo/cipher/Random/Stl/Timer/Poly are logic, CPostIt/CRC32/Debug/FileBase/FileDir/FileLoader/MappedFile/TempFile/Utils/error stay platform; 8 obsolete platform stub files removed","audit/slices regenerated (layer fields; EterLib/Dimm.h now platform)"],"reason":"PORT-PLAN 2A step 6; inventory cross-checked against the clang-derived platform skeleton: every stub is inventoried except 22 declarations 40250 never defines and namespace/typedef spelling variants","tests":["port_map.py check","script/port_gate.sh (macos/android/ios/windows PASS, linux BLOCKED)"]} +{"time":"2026-09-22T16:00:00Z","event":"pack_inventory","scope":"Client/pack (batch 2R)","tool":"tools/epk_scan/epk_scan.py","report":"audit/packs/2R-scan.json","totals":{"registered_packs":136,"entries":54891,"unique_paths":52609,"NONE":20727,"COMPRESS":29812,"SECURITY":4352,"PANAMA":0,"HYBRIDCRYPT":0,"decode_failures":0,"overrides":2282,"overrides_different":2194,"case_collisions":0},"findings":["first-registered pack wins (CEterFileDict multimap, VS2022 STL)","short-layout packer in metin2_patch_{sd,mineral,mundi}: 4 SECURITY files rejected by 40250 CRC check","no server-delivered pack keys needed"]} diff --git a/audit/packs/2R-scan.json b/audit/packs/2R-scan.json new file mode 100644 index 00000000..4631297f --- /dev/null +++ b/audit/packs/2R-scan.json @@ -0,0 +1,2209 @@ +{ + "pack_dir": "Client/pack", + "registered": [ + "patch1", + "patch1_texcache", + "season3_eu", + "season3_eu_texcache", + "patch2", + "patch2_texcache", + "metin2_patch_snow", + "metin2_patch_snow_texcache", + "metin2_patch_snow_dungeon", + "metin2_patch_snow_dungeon_texcache", + "metin2_patch_etc_costume1", + "metin2_patch_etc_costume1_texcache", + "metin2_patch_pet1", + "metin2_patch_pet1_texcache", + "metin2_patch_pet2", + "metin2_patch_pet2_texcache", + "metin2_patch_ramadan_costume", + "metin2_patch_ramadan_costume_texcache", + "metin2_patch_flame", + "metin2_patch_flame_texcache", + "metin2_patch_flame_dungeon", + "metin2_patch_flame_dungeon_texcache", + "metin2_patch_w21_etc", + "metin2_patch_w21_etc_texcache", + "metin2_patch_w21_mobs", + "metin2_patch_w21_mobs_texcache", + "metin2_patch_w21_mobs_m", + "metin2_patch_w21_mobs_m_texcache", + "metin2_patch_dss_box", + "metin2_patch_dss_box_texcache", + "metin2_patch_costume_soccer", + "metin2_patch_costume_soccer_texcache", + "metin2_patch_easter1", + "metin2_patch_easter1_texcache", + "metin2_patch_mineral", + "metin2_patch_mineral_texcache", + "metin2_patch_w20_sound", + "metin2_patch_w20_sound_texcache", + "metin2_patch_ds", + "metin2_patch_ds_texcache", + "metin2_patch_5th_armor", + "metin2_patch_5th_armor_texcache", + "metin2_patch_w20_etc", + "metin2_patch_w20_etc_texcache", + "metin2_patch_dragon_rock", + "metin2_patch_dragon_rock_texcache", + "metin2_patch_dragon_rock_mobs", + "metin2_patch_dragon_rock_mobs_texcache", + "metin2_patch_etc", + "metin2_patch_etc_texcache", + "metin2_patch_xmas", + "metin2_patch_xmas_texcache", + "metin2_patch_eu3", + "metin2_patch_eu3_texcache", + "metin2_patch_eu4", + "metin2_patch_eu4_texcache", + "metin2_patch_mundi", + "metin2_patch_mundi_texcache", + "metin2_patch_sd", + "metin2_patch_sd_texcache", + "metin2_patch_halloween", + "metin2_patch_halloween_texcache", + "metin2_patch_party", + "metin2_patch_party_texcache", + "metin2_patch_dance", + "metin2_patch_dance_texcache", + "pc", + "pc_texcache", + "pc2", + "pc2_texcache", + "monster", + "monster_texcache", + "monster2", + "monster2_texcache", + "effect", + "effect_texcache", + "zone", + "zone_texcache", + "terrain", + "terrain_texcache", + "npc", + "npc_texcache", + "npc2", + "npc2_texcache", + "tree", + "tree_texcache", + "guild", + "guild_texcache", + "item", + "item_texcache", + "textureset", + "textureset_texcache", + "property", + "property_texcache", + "icon", + "icon_texcache", + "season1", + "season1_texcache", + "season2", + "season2_texcache", + "outdoora1", + "outdoora1_texcache", + "outdoora2", + "outdoora2_texcache", + "outdoora3", + "outdoora3_texcache", + "outdoorb1", + "outdoorb1_texcache", + "outdoorb3", + "outdoorb3_texcache", + "outdoorc1", + "outdoorc1_texcache", + "outdoorc3", + "outdoorc3_texcache", + "outdoorsnow1", + "outdoorsnow1_texcache", + "outdoordesert1", + "outdoordesert1_texcache", + "outdoorflame1", + "outdoorflame1_texcache", + "outdoorfielddungeon1", + "outdoorfielddungeon1_texcache", + "outdoort1", + "outdoort1_texcache", + "outdoort2", + "outdoort2_texcache", + "outdoort3", + "outdoort3_texcache", + "outdoort4", + "outdoort4_texcache", + "outdoorwedding", + "outdoorwedding_texcache", + "outdoormilgyo1", + "outdoormilgyo1_texcache", + "indoorspiderdungeon1", + "indoorspiderdungeon1_texcache", + "indoordeviltower1", + "indoordeviltower1_texcache", + "indoormonkeydungeon1", + "indoormonkeydungeon1_texcache", + "indoormonkeydungeon2", + "indoormonkeydungeon2_texcache", + "indoormonkeydungeon3", + "indoormonkeydungeon3_texcache", + "outdoortrent02", + "outdoortrent02_texcache", + "outdoorguild1", + "outdoorguild1_texcache", + "outdoorguild2", + "outdoorguild2_texcache", + "outdoorguild3", + "outdoorguild3_texcache", + "outdoortrent", + "outdoortrent_texcache", + "outdoortrent02", + "outdoortrent02_texcache", + "outdoorduel", + "outdoorduel_texcache", + "outdoorgmguildbuild", + "outdoorgmguildbuild_texcache", + "sound", + "sound_texcache", + "sound", + "sound_texcache", + "sound", + "sound_texcache", + "sound", + "sound_texcache", + "sound", + "sound_texcache", + "sound", + "sound_texcache", + "sound", + "sound_texcache", + "sound_m", + "sound_m_texcache", + "sound_m", + "sound_m_texcache", + "sound_m", + "sound_m_texcache", + "sound_m", + "sound_m_texcache", + "sound_m", + "sound_m_texcache", + "sound_m", + "sound_m_texcache", + "sound_m", + "sound_m_texcache", + "sound2", + "sound2_texcache", + "sound2", + "sound2_texcache", + "bgm", + "bgm_texcache", + "ETC", + "ETC_texcache", + "ETC", + "ETC_texcache", + "locale_ca", + "locale_ca_texcache", + "locale_ae", + "locale_ae_texcache", + "locale_de", + "locale_de_texcache", + "locale_es", + "locale_es_texcache", + "locale_fr", + "locale_fr_texcache", + "locale_gr", + "locale_gr_texcache", + "locale_it", + "locale_it_texcache", + "locale_nl", + "locale_nl_texcache", + "locale_pl", + "locale_pl_texcache", + "locale_pt", + "locale_pt_texcache", + "locale_tr", + "locale_tr_texcache", + "locale_uk", + "locale_uk_texcache", + "locale_bg", + "locale_bg_texcache", + "locale_en", + "locale_en_texcache", + "locale_mx", + "locale_mx_texcache", + "locale_ro", + "locale_ro_texcache", + "locale_ru", + "locale_ru_texcache", + "locale_dk", + "locale_dk_texcache", + "locale_cz", + "locale_cz_texcache", + "locale_hu", + "locale_hu_texcache", + "locale_us", + "locale_us_texcache", + "locale_pa", + "locale_pa_texcache", + "uiscript", + "uiscript_texcache", + "ETC", + "ETC_texcache", + "uiloading", + "uiloading_texcache", + "root" + ], + "missing": [ + "ETC_texcache", + "bgm_texcache", + "effect_texcache", + "guild_texcache", + "icon_texcache", + "indoordeviltower1_texcache", + "indoormonkeydungeon1_texcache", + "indoormonkeydungeon2_texcache", + "indoormonkeydungeon3_texcache", + "indoorspiderdungeon1_texcache", + "item_texcache", + "locale_ae", + "locale_ae_texcache", + "locale_bg", + "locale_bg_texcache", + "locale_ca", + "locale_ca_texcache", + "locale_cz_texcache", + "locale_de_texcache", + "locale_dk_texcache", + "locale_en_texcache", + "locale_es_texcache", + "locale_fr_texcache", + "locale_gr_texcache", + "locale_hu_texcache", + "locale_it_texcache", + "locale_mx", + "locale_mx_texcache", + "locale_nl_texcache", + "locale_pa", + "locale_pa_texcache", + "locale_pl_texcache", + "locale_pt_texcache", + "locale_ro_texcache", + "locale_ru_texcache", + "locale_tr_texcache", + "locale_uk", + "locale_uk_texcache", + "locale_us", + "locale_us_texcache", + "metin2_patch_5th_armor_texcache", + "metin2_patch_costume_soccer_texcache", + "metin2_patch_dance_texcache", + "metin2_patch_ds_texcache", + "metin2_patch_dss_box_texcache", + "metin2_patch_easter1_texcache", + "metin2_patch_etc_costume1_texcache", + "metin2_patch_etc_texcache", + "metin2_patch_eu3_texcache", + "metin2_patch_eu4_texcache", + "metin2_patch_flame_dungeon_texcache", + "metin2_patch_flame_texcache", + "metin2_patch_halloween_texcache", + "metin2_patch_mineral_texcache", + "metin2_patch_mundi_texcache", + "metin2_patch_party_texcache", + "metin2_patch_pet1_texcache", + "metin2_patch_pet2_texcache", + "metin2_patch_ramadan_costume_texcache", + "metin2_patch_sd_texcache", + "metin2_patch_snow_dungeon_texcache", + "metin2_patch_snow_texcache", + "metin2_patch_w20_etc_texcache", + "metin2_patch_w20_sound_texcache", + "metin2_patch_w21_etc_texcache", + "metin2_patch_w21_mobs_m_texcache", + "metin2_patch_w21_mobs_texcache", + "metin2_patch_xmas_texcache", + "monster2_texcache", + "monster_texcache", + "npc2_texcache", + "npc_texcache", + "outdoora1_texcache", + "outdoora2_texcache", + "outdoora3_texcache", + "outdoorb1_texcache", + "outdoorb3_texcache", + "outdoorc1_texcache", + "outdoorc3_texcache", + "outdoordesert1_texcache", + "outdoorduel_texcache", + "outdoorfielddungeon1_texcache", + "outdoorflame1_texcache", + "outdoorgmguildbuild_texcache", + "outdoorguild1_texcache", + "outdoorguild2_texcache", + "outdoorguild3_texcache", + "outdoormilgyo1_texcache", + "outdoorsnow1_texcache", + "outdoort1_texcache", + "outdoort2_texcache", + "outdoort3_texcache", + "outdoort4_texcache", + "outdoortrent02_texcache", + "outdoortrent_texcache", + "outdoorwedding_texcache", + "patch1_texcache", + "patch2_texcache", + "pc2_texcache", + "pc_texcache", + "property_texcache", + "season1_texcache", + "season2_texcache", + "season3_eu_texcache", + "sound2_texcache", + "sound_m_texcache", + "sound_texcache", + "terrain_texcache", + "textureset_texcache", + "tree_texcache", + "uiloading_texcache", + "uiscript_texcache", + "zone_texcache" + ], + "stray_files": [ + "Outdoor.eix", + "Outdoor.epk", + "Psyco.eix", + "Psyco.epk", + "metin2_patch_dragon_rock.rdch", + "metin2_patch_dragon_rock_mobs.rdch", + "outdoorempirebattle1.eix", + "outdoorempirebattle1.epk" + ], + "per_type": { + "COMPRESS": { + "files": 29812, + "stored_bytes": 621693795, + "decoded_bytes": 1359771387 + }, + "SECURITY": { + "files": 4352, + "stored_bytes": 2086156, + "decoded_bytes": 6921973 + }, + "NONE": { + "files": 20727, + "stored_bytes": 773021815, + "decoded_bytes": 773021815 + } + }, + "per_pack": { + "patch1": { + "folder": "*", + "files": 441, + "epk_bytes": 92664320, + "index_encrypted": true, + "types": { + "COMPRESS": 441 + }, + "type_bytes": { + "COMPRESS": 92607839 + } + }, + "season3_eu": { + "folder": "*", + "files": 1191, + "epk_bytes": 29648896, + "index_encrypted": true, + "types": { + "COMPRESS": 395, + "SECURITY": 209, + "NONE": 587 + }, + "type_bytes": { + "COMPRESS": 16059608, + "SECURITY": 201292, + "NONE": 13238292 + } + }, + "patch2": { + "folder": "*", + "files": 1177, + "epk_bytes": 26139762, + "index_encrypted": true, + "types": { + "NONE": 833, + "COMPRESS": 344 + }, + "type_bytes": { + "NONE": 25706532, + "COMPRESS": 433230 + } + }, + "metin2_patch_snow": { + "folder": "*", + "files": 365, + "epk_bytes": 14268853, + "index_encrypted": true, + "types": { + "NONE": 365 + }, + "type_bytes": { + "NONE": 14268853 + } + }, + "metin2_patch_snow_dungeon": { + "folder": "pack/", + "files": 246, + "epk_bytes": 16454147, + "index_encrypted": true, + "types": { + "NONE": 246 + }, + "type_bytes": { + "NONE": 16454147 + } + }, + "metin2_patch_etc_costume1": { + "folder": "pack/", + "files": 237, + "epk_bytes": 16445038, + "index_encrypted": true, + "types": { + "NONE": 237 + }, + "type_bytes": { + "NONE": 16445038 + } + }, + "metin2_patch_pet1": { + "folder": "pack/", + "files": 204, + "epk_bytes": 8774662, + "index_encrypted": true, + "types": { + "NONE": 204 + }, + "type_bytes": { + "NONE": 8774662 + } + }, + "metin2_patch_pet2": { + "folder": "pack/", + "files": 99, + "epk_bytes": 3626211, + "index_encrypted": true, + "types": { + "NONE": 99 + }, + "type_bytes": { + "NONE": 3626211 + } + }, + "metin2_patch_ramadan_costume": { + "folder": "pack/", + "files": 26, + "epk_bytes": 2855300, + "index_encrypted": true, + "types": { + "NONE": 26 + }, + "type_bytes": { + "NONE": 2855300 + } + }, + "metin2_patch_flame": { + "folder": "pack/", + "files": 380, + "epk_bytes": 16641770, + "index_encrypted": true, + "types": { + "NONE": 380 + }, + "type_bytes": { + "NONE": 16641770 + } + }, + "metin2_patch_flame_dungeon": { + "folder": "pack/", + "files": 161, + "epk_bytes": 10628610, + "index_encrypted": true, + "types": { + "NONE": 161 + }, + "type_bytes": { + "NONE": 10628610 + } + }, + "metin2_patch_w21_etc": { + "folder": "pack/", + "files": 23, + "epk_bytes": 281488, + "index_encrypted": true, + "types": { + "NONE": 23 + }, + "type_bytes": { + "NONE": 281488 + } + }, + "metin2_patch_w21_mobs": { + "folder": "pack/", + "files": 136, + "epk_bytes": 10472540, + "index_encrypted": true, + "types": { + "NONE": 136 + }, + "type_bytes": { + "NONE": 10472540 + } + }, + "metin2_patch_w21_mobs_m": { + "folder": "pack/", + "files": 266, + "epk_bytes": 280794, + "index_encrypted": true, + "types": { + "NONE": 266 + }, + "type_bytes": { + "NONE": 280794 + } + }, + "metin2_patch_dss_box": { + "folder": "pack/", + "files": 100, + "epk_bytes": 529060, + "index_encrypted": true, + "types": { + "NONE": 100 + }, + "type_bytes": { + "NONE": 529060 + } + }, + "metin2_patch_costume_soccer": { + "folder": "pack/", + "files": 560, + "epk_bytes": 91509599, + "index_encrypted": true, + "types": { + "NONE": 560 + }, + "type_bytes": { + "NONE": 91509599 + } + }, + "metin2_patch_easter1": { + "folder": "pack/", + "files": 115, + "epk_bytes": 7513344, + "index_encrypted": true, + "types": { + "COMPRESS": 115 + }, + "type_bytes": { + "COMPRESS": 7513344 + } + }, + "metin2_patch_mineral": { + "folder": "pack/", + "files": 16, + "epk_bytes": 501767, + "index_encrypted": true, + "types": { + "NONE": 13, + "COMPRESS": 1, + "SECURITY": 2 + }, + "type_bytes": { + "NONE": 499431, + "COMPRESS": 144, + "SECURITY": 2144 + } + }, + "metin2_patch_w20_sound": { + "folder": "pack/", + "files": 976, + "epk_bytes": 13982647, + "index_encrypted": true, + "types": { + "NONE": 976 + }, + "type_bytes": { + "NONE": 13982647 + } + }, + "metin2_patch_ds": { + "folder": "pack/", + "files": 177, + "epk_bytes": 1769171, + "index_encrypted": true, + "types": { + "NONE": 177 + }, + "type_bytes": { + "NONE": 1769171 + } + }, + "metin2_patch_5th_armor": { + "folder": "pack/", + "files": 76, + "epk_bytes": 7757795, + "index_encrypted": true, + "types": { + "NONE": 76 + }, + "type_bytes": { + "NONE": 7757795 + } + }, + "metin2_patch_w20_etc": { + "folder": "pack/", + "files": 21, + "epk_bytes": 389376, + "index_encrypted": true, + "types": { + "COMPRESS": 21 + }, + "type_bytes": { + "COMPRESS": 389376 + } + }, + "metin2_patch_dragon_rock": { + "folder": "pack/", + "files": 1508, + "epk_bytes": 55554921, + "index_encrypted": true, + "types": { + "NONE": 1508 + }, + "type_bytes": { + "NONE": 55554921 + } + }, + "metin2_patch_dragon_rock_texcache": { + "folder": "pack/", + "files": 380, + "epk_bytes": 47697152, + "index_encrypted": true, + "types": { + "COMPRESS": 380 + }, + "type_bytes": { + "COMPRESS": 47697152 + } + }, + "metin2_patch_dragon_rock_mobs": { + "folder": "pack/", + "files": 2405, + "epk_bytes": 61838028, + "index_encrypted": true, + "types": { + "NONE": 2405 + }, + "type_bytes": { + "NONE": 61838028 + } + }, + "metin2_patch_dragon_rock_mobs_texcache": { + "folder": "pack/", + "files": 129, + "epk_bytes": 17801408, + "index_encrypted": true, + "types": { + "NONE": 129 + }, + "type_bytes": { + "NONE": 17801408 + } + }, + "metin2_patch_etc": { + "folder": "pack/", + "files": 433, + "epk_bytes": 5842764, + "index_encrypted": true, + "types": { + "NONE": 433 + }, + "type_bytes": { + "NONE": 5842764 + } + }, + "metin2_patch_xmas": { + "folder": "pack/", + "files": 419, + "epk_bytes": 21194603, + "index_encrypted": true, + "types": { + "NONE": 419 + }, + "type_bytes": { + "NONE": 21194603 + } + }, + "metin2_patch_eu3": { + "folder": "pack/", + "files": 1517, + "epk_bytes": 32184576, + "index_encrypted": true, + "types": { + "COMPRESS": 1517 + }, + "type_bytes": { + "COMPRESS": 32184576 + } + }, + "metin2_patch_eu4": { + "folder": "pack/", + "files": 164, + "epk_bytes": 10767104, + "index_encrypted": true, + "types": { + "COMPRESS": 164 + }, + "type_bytes": { + "COMPRESS": 10767104 + } + }, + "metin2_patch_mundi": { + "folder": "pack/", + "files": 528, + "epk_bytes": 58156360, + "index_encrypted": true, + "types": { + "NONE": 527, + "SECURITY": 1 + }, + "type_bytes": { + "NONE": 58153520, + "SECURITY": 2824 + } + }, + "metin2_patch_sd": { + "folder": "pack/", + "files": 287, + "epk_bytes": 13939014, + "index_encrypted": true, + "types": { + "COMPRESS": 110, + "NONE": 175, + "SECURITY": 2 + }, + "type_bytes": { + "COMPRESS": 23134, + "NONE": 13913456, + "SECURITY": 632 + } + }, + "metin2_patch_halloween": { + "folder": "pack/", + "files": 75, + "epk_bytes": 6723010, + "index_encrypted": true, + "types": { + "NONE": 75 + }, + "type_bytes": { + "NONE": 6723010 + } + }, + "metin2_patch_party": { + "folder": "pack/", + "files": 168, + "epk_bytes": 13717291, + "index_encrypted": true, + "types": { + "NONE": 168 + }, + "type_bytes": { + "NONE": 13717291 + } + }, + "metin2_patch_dance": { + "folder": "pack/", + "files": 28, + "epk_bytes": 7745752, + "index_encrypted": true, + "types": { + "NONE": 28 + }, + "type_bytes": { + "NONE": 7745752 + } + }, + "pc": { + "folder": "d:/ymir work/pc/", + "files": 2705, + "epk_bytes": 67204864, + "index_encrypted": true, + "types": { + "COMPRESS": 2705 + }, + "type_bytes": { + "COMPRESS": 67204864 + } + }, + "pc2": { + "folder": "d:/ymir work/pc2/", + "files": 1739, + "epk_bytes": 62856192, + "index_encrypted": true, + "types": { + "NONE": 682, + "SECURITY": 877, + "COMPRESS": 180 + }, + "type_bytes": { + "NONE": 43632395, + "SECURITY": 447364, + "COMPRESS": 18571515 + } + }, + "monster": { + "folder": "d:/ymir work/monster/", + "files": 3092, + "epk_bytes": 65224960, + "index_encrypted": true, + "types": { + "NONE": 90, + "COMPRESS": 3002 + }, + "type_bytes": { + "NONE": 3427835, + "COMPRESS": 61364720 + } + }, + "monster2": { + "folder": "d:/ymir work/monster2/", + "files": 1359, + "epk_bytes": 31736832, + "index_encrypted": true, + "types": { + "NONE": 639, + "SECURITY": 664, + "COMPRESS": 56 + }, + "type_bytes": { + "NONE": 22377826, + "SECURITY": 181832, + "COMPRESS": 8773500 + } + }, + "effect": { + "folder": "d:/ymir work/effect/", + "files": 819, + "epk_bytes": 3719424, + "index_encrypted": true, + "types": { + "COMPRESS": 803, + "NONE": 16 + }, + "type_bytes": { + "COMPRESS": 3335234, + "NONE": 284237 + } + }, + "zone": { + "folder": "d:/ymir work/zone/", + "files": 3009, + "epk_bytes": 93092608, + "index_encrypted": true, + "types": { + "COMPRESS": 877, + "NONE": 2132 + }, + "type_bytes": { + "COMPRESS": 57301448, + "NONE": 35390408 + } + }, + "terrain": { + "folder": "d:/ymir work/terrainmaps/", + "files": 279, + "epk_bytes": 10716416, + "index_encrypted": true, + "types": { + "COMPRESS": 251, + "NONE": 28 + }, + "type_bytes": { + "COMPRESS": 10430109, + "NONE": 246816 + } + }, + "npc": { + "folder": "d:/ymir work/npc/", + "files": 829, + "epk_bytes": 21501440, + "index_encrypted": true, + "types": { + "COMPRESS": 71, + "NONE": 352, + "SECURITY": 406 + }, + "type_bytes": { + "COMPRESS": 8947759, + "NONE": 12374222, + "SECURITY": 79112 + } + }, + "npc2": { + "folder": "d:/ymir work/npc2/", + "files": 14, + "epk_bytes": 301568, + "index_encrypted": true, + "types": { + "SECURITY": 6, + "NONE": 6, + "COMPRESS": 2 + }, + "type_bytes": { + "SECURITY": 1208, + "NONE": 51164, + "COMPRESS": 247854 + } + }, + "tree": { + "folder": "d:/ymir work/tree/", + "files": 135, + "epk_bytes": 3719424, + "index_encrypted": true, + "types": { + "NONE": 1, + "COMPRESS": 134 + }, + "type_bytes": { + "NONE": 87536, + "COMPRESS": 3617643 + } + }, + "guild": { + "folder": "d:/ymir work/guild/", + "files": 188, + "epk_bytes": 6400512, + "index_encrypted": true, + "types": { + "NONE": 54, + "COMPRESS": 69, + "SECURITY": 65 + }, + "type_bytes": { + "NONE": 1354393, + "COMPRESS": 4999959, + "SECURITY": 21036 + } + }, + "item": { + "folder": "d:/ymir work/item/", + "files": 2515, + "epk_bytes": 5949440, + "index_encrypted": true, + "types": { + "NONE": 2435, + "SECURITY": 2, + "COMPRESS": 78 + }, + "type_bytes": { + "NONE": 2605883, + "SECURITY": 49040, + "COMPRESS": 3043803 + } + }, + "textureset": { + "folder": "textureset/", + "files": 44, + "epk_bytes": 21248, + "index_encrypted": true, + "types": { + "SECURITY": 44 + }, + "type_bytes": { + "SECURITY": 16064 + } + }, + "property": { + "folder": "property/", + "files": 1330, + "epk_bytes": 340992, + "index_encrypted": true, + "types": { + "COMPRESS": 1330 + }, + "type_bytes": { + "COMPRESS": 340992 + } + }, + "icon": { + "folder": "icon/", + "files": 1952, + "epk_bytes": 5232640, + "index_encrypted": true, + "types": { + "COMPRESS": 1730, + "NONE": 221, + "SECURITY": 1 + }, + "type_bytes": { + "COMPRESS": 4940460, + "NONE": 27088, + "SECURITY": 708 + } + }, + "season1": { + "folder": "season1/", + "files": 4410, + "epk_bytes": 54050048, + "index_encrypted": true, + "types": { + "COMPRESS": 4410 + }, + "type_bytes": { + "COMPRESS": 54050048 + } + }, + "season2": { + "folder": "season2/", + "files": 2913, + "epk_bytes": 20960768, + "index_encrypted": true, + "types": { + "SECURITY": 934, + "COMPRESS": 1847, + "NONE": 132 + }, + "type_bytes": { + "SECURITY": 489352, + "COMPRESS": 15357461, + "NONE": 4733891 + } + }, + "outdoora1": { + "folder": "metin2_map_a1/", + "files": 204, + "epk_bytes": 1652736, + "index_encrypted": true, + "types": { + "COMPRESS": 204 + }, + "type_bytes": { + "COMPRESS": 1652736 + } + }, + "outdoora2": { + "folder": "map_a2/", + "files": 363, + "epk_bytes": 2888704, + "index_encrypted": true, + "types": { + "SECURITY": 111, + "COMPRESS": 252 + }, + "type_bytes": { + "SECURITY": 47548, + "COMPRESS": 2792043 + } + }, + "outdoora3": { + "folder": "metin2_map_a3/", + "files": 165, + "epk_bytes": 1303296, + "index_encrypted": true, + "types": { + "SECURITY": 53, + "COMPRESS": 112 + }, + "type_bytes": { + "SECURITY": 44924, + "COMPRESS": 1236047 + } + }, + "outdoorb1": { + "folder": "metin2_map_b1/", + "files": 224, + "epk_bytes": 1995264, + "index_encrypted": true, + "types": { + "SECURITY": 64, + "COMPRESS": 160 + }, + "type_bytes": { + "SECURITY": 46456, + "COMPRESS": 1918381 + } + }, + "outdoorb3": { + "folder": "metin2_map_b3/", + "files": 183, + "epk_bytes": 1964288, + "index_encrypted": true, + "types": { + "SECURITY": 55, + "COMPRESS": 128 + }, + "type_bytes": { + "SECURITY": 38844, + "COMPRESS": 1901017 + } + }, + "outdoorc1": { + "folder": "metin2_map_c1/", + "files": 204, + "epk_bytes": 1331456, + "index_encrypted": true, + "types": { + "SECURITY": 64, + "COMPRESS": 140 + }, + "type_bytes": { + "SECURITY": 36952, + "COMPRESS": 1267246 + } + }, + "outdoorc3": { + "folder": "metin2_map_c3/", + "files": 164, + "epk_bytes": 1254144, + "index_encrypted": true, + "types": { + "SECURITY": 52, + "COMPRESS": 112 + }, + "type_bytes": { + "SECURITY": 28112, + "COMPRESS": 1203217 + } + }, + "outdoorsnow1": { + "folder": "map_n_snowm_01/", + "files": 364, + "epk_bytes": 2994176, + "index_encrypted": true, + "types": { + "SECURITY": 112, + "COMPRESS": 252 + }, + "type_bytes": { + "SECURITY": 57352, + "COMPRESS": 2887641 + } + }, + "outdoordesert1": { + "folder": "metin2_map_n_desert_01/", + "files": 364, + "epk_bytes": 2403328, + "index_encrypted": true, + "types": { + "SECURITY": 112, + "COMPRESS": 252 + }, + "type_bytes": { + "SECURITY": 75280, + "COMPRESS": 2279373 + } + }, + "outdoorflame1": { + "folder": "metin2_map_n_flame_01/", + "files": 364, + "epk_bytes": 3019776, + "index_encrypted": true, + "types": { + "SECURITY": 112, + "COMPRESS": 252 + }, + "type_bytes": { + "SECURITY": 16008, + "COMPRESS": 2346527 + } + }, + "outdoorfielddungeon1": { + "folder": "map_b_fielddungeon/", + "files": 39, + "epk_bytes": 297728, + "index_encrypted": true, + "types": { + "SECURITY": 15, + "COMPRESS": 24 + }, + "type_bytes": { + "SECURITY": 5964, + "COMPRESS": 286363 + } + }, + "outdoort1": { + "folder": "metin2_map_t1/", + "files": 93, + "epk_bytes": 1157888, + "index_encrypted": true, + "types": { + "COMPRESS": 93 + }, + "type_bytes": { + "COMPRESS": 649708 + } + }, + "outdoort2": { + "folder": "metin2_map_t2/", + "files": 13, + "epk_bytes": 161792, + "index_encrypted": true, + "types": { + "COMPRESS": 13 + }, + "type_bytes": { + "COMPRESS": 92080 + } + }, + "outdoort3": { + "folder": "metin2_map_t3/", + "files": 13, + "epk_bytes": 86272, + "index_encrypted": true, + "types": { + "COMPRESS": 13 + }, + "type_bytes": { + "COMPRESS": 77908 + } + }, + "outdoort4": { + "folder": "metin2_map_t4/", + "files": 13, + "epk_bytes": 84224, + "index_encrypted": true, + "types": { + "COMPRESS": 13 + }, + "type_bytes": { + "COMPRESS": 76833 + } + }, + "outdoorwedding": { + "folder": "metin2_map_wedding_01/", + "files": 12, + "epk_bytes": 89344, + "index_encrypted": true, + "types": { + "SECURITY": 5, + "COMPRESS": 7 + }, + "type_bytes": { + "SECURITY": 2940, + "COMPRESS": 84640 + } + }, + "outdoormilgyo1": { + "folder": "metin2_map_milgyo/", + "files": 164, + "epk_bytes": 1072640, + "index_encrypted": true, + "types": { + "SECURITY": 51, + "COMPRESS": 112, + "NONE": 1 + }, + "type_bytes": { + "SECURITY": 25716, + "COMPRESS": 1026141, + "NONE": 104 + } + }, + "indoorspiderdungeon1": { + "folder": "metin2_map_spiderdungeon/", + "files": 93, + "epk_bytes": 64768, + "index_encrypted": true, + "types": { + "COMPRESS": 93 + }, + "type_bytes": { + "COMPRESS": 50584 + } + }, + "indoordeviltower1": { + "folder": "metin2_map_deviltower1/", + "files": 93, + "epk_bytes": 93184, + "index_encrypted": true, + "types": { + "SECURITY": 30, + "COMPRESS": 63 + }, + "type_bytes": { + "SECURITY": 3696, + "COMPRESS": 77660 + } + }, + "indoormonkeydungeon1": { + "folder": "metin2_map_monkeydungeon/", + "files": 93, + "epk_bytes": 113152, + "index_encrypted": true, + "types": { + "SECURITY": 30, + "COMPRESS": 63 + }, + "type_bytes": { + "SECURITY": 6424, + "COMPRESS": 94102 + } + }, + "indoormonkeydungeon2": { + "folder": "metin2_map_monkeydungeon_02/", + "files": 93, + "epk_bytes": 129024, + "index_encrypted": true, + "types": { + "SECURITY": 30, + "COMPRESS": 63 + }, + "type_bytes": { + "SECURITY": 6480, + "COMPRESS": 110559 + } + }, + "indoormonkeydungeon3": { + "folder": "metin2_map_monkeydungeon_03/", + "files": 93, + "epk_bytes": 127488, + "index_encrypted": true, + "types": { + "SECURITY": 30, + "COMPRESS": 63 + }, + "type_bytes": { + "SECURITY": 6448, + "COMPRESS": 109365 + } + }, + "outdoortrent02": { + "folder": "metin2_map_trent02/ ", + "files": 162, + "epk_bytes": 863488, + "index_encrypted": true, + "types": { + "SECURITY": 50, + "COMPRESS": 112 + }, + "type_bytes": { + "SECURITY": 19408, + "COMPRESS": 822534 + } + }, + "outdoorguild1": { + "folder": "metin2_map_guild_01/", + "files": 42, + "epk_bytes": 317184, + "index_encrypted": true, + "types": { + "SECURITY": 14, + "COMPRESS": 28 + }, + "type_bytes": { + "SECURITY": 7400, + "COMPRESS": 303999 + } + }, + "outdoorguild2": { + "folder": "metin2_map_guild_02/", + "files": 42, + "epk_bytes": 412160, + "index_encrypted": true, + "types": { + "SECURITY": 14, + "COMPRESS": 28 + }, + "type_bytes": { + "SECURITY": 6760, + "COMPRESS": 276000 + } + }, + "outdoorguild3": { + "folder": "metin2_map_guild_03/", + "files": 42, + "epk_bytes": 334080, + "index_encrypted": true, + "types": { + "SECURITY": 14, + "COMPRESS": 28 + }, + "type_bytes": { + "SECURITY": 9216, + "COMPRESS": 299503 + } + }, + "outdoortrent": { + "folder": "metin2_map_trent/", + "files": 43, + "epk_bytes": 754176, + "index_encrypted": true, + "types": { + "SECURITY": 15, + "COMPRESS": 28 + }, + "type_bytes": { + "SECURITY": 9460, + "COMPRESS": 356757 + } + }, + "outdoorduel": { + "folder": "metin2_map_duel/", + "files": 13, + "epk_bytes": 72192, + "index_encrypted": true, + "types": { + "SECURITY": 6, + "COMPRESS": 7 + }, + "type_bytes": { + "SECURITY": 3736, + "COMPRESS": 66570 + } + }, + "outdoorgmguildbuild": { + "folder": "gm_guild_build/", + "files": 13, + "epk_bytes": 105216, + "index_encrypted": true, + "types": { + "SECURITY": 5, + "COMPRESS": 7, + "NONE": 1 + }, + "type_bytes": { + "SECURITY": 2460, + "COMPRESS": 44228, + "NONE": 166 + } + }, + "sound": { + "folder": "sound/ambience/", + "files": 1025, + "epk_bytes": 50380544, + "index_encrypted": true, + "types": { + "NONE": 1025 + }, + "type_bytes": { + "NONE": 50248484 + } + }, + "sound_m": { + "folder": "sound/ambience/", + "files": 1478, + "epk_bytes": 378368, + "index_encrypted": true, + "types": { + "COMPRESS": 1478 + }, + "type_bytes": { + "COMPRESS": 220814 + } + }, + "sound2": { + "folder": "sound/monster2/", + "files": 1307, + "epk_bytes": 31777280, + "index_encrypted": true, + "types": { + "COMPRESS": 916, + "NONE": 390, + "SECURITY": 1 + }, + "type_bytes": { + "COMPRESS": 140360, + "NONE": 31490892, + "SECURITY": 372 + } + }, + "bgm": { + "folder": "bgm/", + "files": 5, + "epk_bytes": 9334272, + "index_encrypted": true, + "types": { + "NONE": 5 + }, + "type_bytes": { + "NONE": 9333659 + } + }, + "ETC": { + "folder": "d:/ymir work/special/", + "files": 1230, + "epk_bytes": 37156586, + "index_encrypted": true, + "types": { + "NONE": 1185, + "COMPRESS": 45 + }, + "type_bytes": { + "NONE": 37148123, + "COMPRESS": 8463 + } + }, + "locale_de": { + "folder": "locale/de/", + "files": 236, + "epk_bytes": 3886848, + "index_encrypted": true, + "types": { + "COMPRESS": 236 + }, + "type_bytes": { + "COMPRESS": 3886848 + } + }, + "locale_es": { + "folder": "locale/es/", + "files": 231, + "epk_bytes": 3715584, + "index_encrypted": true, + "types": { + "COMPRESS": 231 + }, + "type_bytes": { + "COMPRESS": 3715584 + } + }, + "locale_fr": { + "folder": "locale/fr/", + "files": 234, + "epk_bytes": 3753472, + "index_encrypted": true, + "types": { + "COMPRESS": 234 + }, + "type_bytes": { + "COMPRESS": 3753472 + } + }, + "locale_gr": { + "folder": "locale/gr/", + "files": 235, + "epk_bytes": 3728128, + "index_encrypted": true, + "types": { + "COMPRESS": 235 + }, + "type_bytes": { + "COMPRESS": 3728128 + } + }, + "locale_it": { + "folder": "locale/it/", + "files": 231, + "epk_bytes": 3723520, + "index_encrypted": true, + "types": { + "COMPRESS": 231 + }, + "type_bytes": { + "COMPRESS": 3723520 + } + }, + "locale_nl": { + "folder": "locale/nl/", + "files": 231, + "epk_bytes": 3768320, + "index_encrypted": true, + "types": { + "COMPRESS": 231 + }, + "type_bytes": { + "COMPRESS": 3768320 + } + }, + "locale_pl": { + "folder": "locale/pl/", + "files": 231, + "epk_bytes": 3731200, + "index_encrypted": true, + "types": { + "COMPRESS": 231 + }, + "type_bytes": { + "COMPRESS": 3731200 + } + }, + "locale_pt": { + "folder": "locale/pt/", + "files": 231, + "epk_bytes": 3719936, + "index_encrypted": true, + "types": { + "COMPRESS": 231 + }, + "type_bytes": { + "COMPRESS": 3719936 + } + }, + "locale_tr": { + "folder": "locale/tr/", + "files": 233, + "epk_bytes": 3705088, + "index_encrypted": true, + "types": { + "COMPRESS": 233 + }, + "type_bytes": { + "COMPRESS": 3705088 + } + }, + "locale_en": { + "folder": "locale/en/", + "files": 231, + "epk_bytes": 3703296, + "index_encrypted": true, + "types": { + "COMPRESS": 231 + }, + "type_bytes": { + "COMPRESS": 3703296 + } + }, + "locale_ro": { + "folder": "locale/ro/", + "files": 231, + "epk_bytes": 3724544, + "index_encrypted": true, + "types": { + "COMPRESS": 231 + }, + "type_bytes": { + "COMPRESS": 3724544 + } + }, + "locale_ru": { + "folder": "locale/ru/", + "files": 231, + "epk_bytes": 3728384, + "index_encrypted": true, + "types": { + "COMPRESS": 231 + }, + "type_bytes": { + "COMPRESS": 3728384 + } + }, + "locale_dk": { + "folder": "locale/dk/", + "files": 231, + "epk_bytes": 3699968, + "index_encrypted": true, + "types": { + "COMPRESS": 231 + }, + "type_bytes": { + "COMPRESS": 3699968 + } + }, + "locale_cz": { + "folder": "locale/cz/", + "files": 231, + "epk_bytes": 3728128, + "index_encrypted": true, + "types": { + "COMPRESS": 231 + }, + "type_bytes": { + "COMPRESS": 3728128 + } + }, + "locale_hu": { + "folder": "locale/hu/", + "files": 231, + "epk_bytes": 3702016, + "index_encrypted": true, + "types": { + "COMPRESS": 231 + }, + "type_bytes": { + "COMPRESS": 3702016 + } + }, + "uiscript": { + "folder": "uiscript/", + "files": 94, + "epk_bytes": 85592, + "index_encrypted": true, + "types": { + "SECURITY": 94 + }, + "type_bytes": { + "SECURITY": 85592 + } + }, + "uiloading": { + "folder": "d:/ymir work/uiloading/", + "files": 49, + "epk_bytes": 10192384, + "index_encrypted": true, + "types": { + "COMPRESS": 49 + }, + "type_bytes": { + "COMPRESS": 10192384 + } + }, + "root": { + "folder": "", + "files": 90, + "epk_bytes": 520704, + "index_encrypted": true, + "types": { + "COMPRESS": 90 + }, + "type_bytes": { + "COMPRESS": 520704 + } + } + }, + "unique_files": 52609, + "overrides": { + "count": 2282, + "different_content": 2194, + "by_pair": [ + [ + "metin2_patch_snow > outdoorsnow1", + 364 + ], + [ + "metin2_patch_flame > outdoorflame1", + 364 + ], + [ + "patch1 > zone", + 314 + ], + [ + "season3_eu > season2", + 264 + ], + [ + "metin2_patch_etc > pc2", + 179 + ], + [ + "metin2_patch_etc > pc", + 174 + ], + [ + "metin2_patch_ds > icon", + 145 + ], + [ + "patch2 > sound_m", + 66 + ], + [ + "patch1 > pc", + 58 + ], + [ + "metin2_patch_w20_sound > sound2", + 45 + ], + [ + "metin2_patch_mundi > metin2_patch_halloween", + 42 + ], + [ + "patch2 > sound2", + 40 + ], + [ + "patch1 > terrain", + 28 + ], + [ + "patch1 > pc2", + 27 + ], + [ + "season3_eu > zone", + 22 + ], + [ + "metin2_patch_xmas > metin2_patch_eu3", + 14 + ], + [ + "metin2_patch_5th_armor > npc", + 12 + ], + [ + "season3_eu > outdoora2", + 10 + ], + [ + "metin2_patch_flame > terrain", + 9 + ], + [ + "patch1 > tree", + 9 + ], + [ + "metin2_patch_snow_dungeon > metin2_patch_dss_box", + 8 + ], + [ + "metin2_patch_dance > pc", + 8 + ], + [ + "metin2_patch_snow_dungeon > icon", + 8 + ], + [ + "metin2_patch_flame_dungeon > metin2_patch_w21_mobs", + 5 + ], + [ + "season3_eu > patch2", + 4 + ], + [ + "metin2_patch_w20_etc > pc", + 4 + ], + [ + "metin2_patch_w20_etc > pc2", + 4 + ], + [ + "metin2_patch_dance > pc2", + 4 + ], + [ + "metin2_patch_xmas > icon", + 4 + ], + [ + "metin2_patch_flame_dungeon > metin2_patch_w21_mobs_m", + 3 + ], + [ + "patch1 > metin2_patch_dragon_rock", + 3 + ], + [ + "patch1 > metin2_patch_eu4", + 3 + ], + [ + "metin2_patch_eu4 > metin2_patch_mundi", + 3 + ], + [ + "season3_eu > item", + 3 + ], + [ + "season3_eu > property", + 3 + ], + [ + "metin2_patch_flame > ETC", + 3 + ], + [ + "patch2 > ETC", + 3 + ], + [ + "season3_eu > metin2_patch_etc", + 2 + ], + [ + "patch2 > metin2_patch_eu3", + 2 + ], + [ + "metin2_patch_easter1 > metin2_patch_eu4", + 2 + ], + [ + "patch2 > pc", + 2 + ], + [ + "metin2_patch_sd > monster2", + 2 + ], + [ + "metin2_patch_sd > zone", + 2 + ], + [ + "season3_eu > ETC", + 2 + ], + [ + "metin2_patch_snow > metin2_patch_snow_dungeon", + 1 + ], + [ + "metin2_patch_dragon_rock > metin2_patch_dragon_rock_mobs", + 1 + ], + [ + "metin2_patch_dragon_rock_texcache > metin2_patch_dragon_rock_mobs_texcache", + 1 + ], + [ + "metin2_patch_dragon_rock_mobs > metin2_patch_eu3", + 1 + ], + [ + "season3_eu > monster2", + 1 + ], + [ + "patch1 > npc", + 1 + ], + [ + "metin2_patch_eu3 > textureset", + 1 + ], + [ + "metin2_patch_flame > textureset", + 1 + ], + [ + "metin2_patch_snow > ETC", + 1 + ] + ], + "different_examples": [ + { + "file": "d:/ymir work/zone/dungeon/haven_dungeon/skipia_boss_lightingmap03.dds", + "kept": "season3_eu", + "shadowed": "patch2", + "same_content": false + }, + { + "file": "d:/ymir work/zone/dungeon/haven_dungeon/skipia_boss_lightingmap01.dds", + "kept": "season3_eu", + "shadowed": "patch2", + "same_content": false + }, + { + "file": "d:/ymir work/zone/dungeon/haven_dungeon/skipia_boss_lightingmap02.dds", + "kept": "season3_eu", + "shadowed": "patch2", + "same_content": false + }, + { + "file": "d:/ymir work/zone/dungeon/haven_dungeon/skipia_boss_lightingmap04.dds", + "kept": "season3_eu", + "shadowed": "patch2", + "same_content": false + }, + { + "file": "d:/ymir work/tree/compositemapb1.dds", + "kept": "patch1", + "shadowed": "metin2_patch_dragon_rock", + "same_content": false + }, + { + "file": "d:/ymir work/tree/compositemapb2.dds", + "kept": "patch1", + "shadowed": "metin2_patch_dragon_rock", + "same_content": false + }, + { + "file": "d:/ymir work/tree/compositemapb3.dds", + "kept": "patch1", + "shadowed": "metin2_patch_dragon_rock", + "same_content": false + }, + { + "file": "d:/ymir work/effect/monster2/smoke_dirt1.dds", + "kept": "metin2_patch_dragon_rock_texcache", + "shadowed": "metin2_patch_dragon_rock_mobs_texcache", + "same_content": false + }, + { + "file": "d:/ymir work/item/weapon/weapon_choegogeup01_04.dds", + "kept": "season3_eu", + "shadowed": "metin2_patch_etc", + "same_content": false + }, + { + "file": "d:/ymir work/effect/background/smoke02.dds", + "kept": "patch2", + "shadowed": "metin2_patch_eu3", + "same_content": false + }, + { + "file": "d:/ymir work/effect/background/smoke03.dds", + "kept": "patch2", + "shadowed": "metin2_patch_eu3", + "same_content": false + }, + { + "file": "d:/ymir work/effect/monster2/impact3.mse", + "kept": "metin2_patch_dragon_rock_mobs", + "shadowed": "metin2_patch_eu3", + "same_content": false + }, + { + "file": "d:/ymir work/npc2/phoenix1/motlist.txt", + "kept": "metin2_patch_xmas", + "shadowed": "metin2_patch_eu3", + "same_content": false + }, + { + "file": "d:/ymir work/npc2/phoenix1/phoenix1.dds", + "kept": "metin2_patch_xmas", + "shadowed": "metin2_patch_eu3", + "same_content": false + }, + { + "file": "d:/ymir work/npc2/phoenix1/phoenix1.gr2", + "kept": "metin2_patch_xmas", + "shadowed": "metin2_patch_eu3", + "same_content": false + }, + { + "file": "d:/ymir work/npc2/phoenix1/phoenix1.msm", + "kept": "metin2_patch_xmas", + "shadowed": "metin2_patch_eu3", + "same_content": false + }, + { + "file": "d:/ymir work/npc2/phoenix1/run.gr2", + "kept": "metin2_patch_xmas", + "shadowed": "metin2_patch_eu3", + "same_content": false + }, + { + "file": "d:/ymir work/npc2/phoenix1/run.msa", + "kept": "metin2_patch_xmas", + "shadowed": "metin2_patch_eu3", + "same_content": false + }, + { + "file": "d:/ymir work/npc2/phoenix1/wait.gr2", + "kept": "metin2_patch_xmas", + "shadowed": "metin2_patch_eu3", + "same_content": false + }, + { + "file": "d:/ymir work/npc2/phoenix1/wait.msa", + "kept": "metin2_patch_xmas", + "shadowed": "metin2_patch_eu3", + "same_content": false + } + ] + }, + "paths": { + "filename_crc_mismatch": 0, + "not_lowercase": 0, + "backslash": 0, + "case_collisions": [], + "top_prefixes": [ + [ + "d:/ymir work/", + 26955 + ], + [ + "icon/item/", + 1873 + ], + [ + "sound/monster2/", + 1691 + ], + [ + "sound/monster/", + 1588 + ], + [ + "property/n/", + 847 + ], + [ + "sound/pc/", + 838 + ], + [ + "sound/pc2/", + 761 + ], + [ + "season2/metin2_map_skipia_dungeon_01/", + 510 + ], + [ + "season2/metin2_map_skipia_dungeon_02/", + 474 + ], + [ + "property/b/", + 461 + ], + [ + "season1/effect/", + 443 + ], + [ + "icon/hair/", + 412 + ], + [ + "season2/map_n_snowm_02/", + 365 + ], + [ + "season2/metin2_map_n_desert_02/", + 365 + ], + [ + "season2/metin2_map_n_flame_02/", + 365 + ], + [ + "season2/metin2_map_a2_1/", + 364 + ], + [ + "season1/metin2_map_skipia_dungeon_01/", + 363 + ], + [ + "season1/metin2_map_skipia_dungeon_02/", + 363 + ], + [ + "season1/monster/", + 359 + ], + [ + "season1/npc/", + 316 + ], + [ + "season2/monster/", + 258 + ], + [ + "locale/de/", + 236 + ], + [ + "locale/gr/", + 235 + ], + [ + "locale/fr/", + 234 + ], + [ + "locale/tr/", + 233 + ] + ] + } +} diff --git a/docs/PORT-PLAN.md b/docs/PORT-PLAN.md index ed113673..2c33bc4a 100644 --- a/docs/PORT-PLAN.md +++ b/docs/PORT-PLAN.md @@ -81,7 +81,7 @@ extension/third_party/cpython-2.7.18/ # 静态库(2P 批次 | 1 | 删除运行时不加载的 106 个 `*_system.gd` 及其测试 | 完成(a989b8f1) | | — | 定下目录结构,评估内嵌 Python;Android 独立程序验证 | 完成(9d0e50de、b02c49bb) | | **2A** | 基础:Win32 类型层、参考公共头的最小闭包、`port_logic` CMake 目标、platform 接口骨架、头文件可编译门禁、port-map 重新基线 | **进行中**(剩 Python 库闭包头,等 2P) | -| **2R** | 资源包能力盘点:EPK 类型/密钥/覆盖顺序/路径大小写/移动端交付 | **下一步**,可与 2A 并行(只读分析 + 独立工具) | +| 2R | 资源包能力盘点:EPK 类型/密钥/覆盖顺序/路径大小写/移动端交付 | 完成:只有 NONE/COMPRESS/SECURITY,不需要服务器密钥;设备上直接读 EPK | | 2P | CPython 2.7.18 编进 libmtgodot,五个平台分别配置和验证 | 2A 之后 | | 2D | 数据源切到 40250(msm 路径、proto、资源根、严格资源测试) | proto 部分在 2A 之后;资源根在 2R 之后 | | **2V0** | UI 壳:真实 `PythonLauncher/wndMgr/grp/app` + platform UI,其他玩法模块用桩,显示并操作 Logo/Popup | 需要 2A、2P 和 root/uiscript 子集 | @@ -202,23 +202,80 @@ extension/third_party/cpython-2.7.18/ # 静态库(2P 批次 已知的无关问题:完整 Android 扩展构建在 `extension/src/net/text_codec.cpp` 失败(bionic 从 API 28 才声明 `iconv`, 而构建用 API 24;c9389431 引入);仓库里的 `build-android/` 缓存还指向旧路径 `mtgodot-poc`。 -### 批次 2R:资源包能力盘点 +### 批次 2R:资源包能力盘点(完成,2026-09-22) -40250 的读取链不只是 `EterPack.cpp`,还有 `EterPackManager.cpp`(多包覆盖顺序、路径归一化)、 -`EterPackPolicy_CSHybridCrypt.cpp`(HybridCrypt,密钥可能来自登录/握手阶段的服务器下发)、`CMappedFile`、`CLZO`, -以及 TEA/Panama/Camellia/Twofish/XTEA。压缩类型有六种:`NONE`、`COMPRESS`、`SECURITY`、`PANAMA`、 -`HYBRIDCRYPT`、`HYBRIDCRYPT_WITHSDB`。`Client/pack` 有 217 个文件、约 1.3 GB。 +工具:`tools/epk_scan/epk_scan.py`(只读)。`summary` 统计并写 `audit/packs/2R-scan.json`;`verify` 解码全部可本地解的条目。 +索引密钥和 SECURITY 密钥运行时从参考树 `EterPack/EterPack.cpp` 读取,仓库里没有密钥。读取链照 40250:`PackInitialize` +(`pack/Index`)→ `CEterPackManager::RegisterPack`(每个包再注册 `_texcache`,最后 `RegisterRootPack("pack/root")`) +→ `CEterPack::__BuildIndex`(`.eix` 全部是 `MCOZ`:TEA + LZO 包着 `EPKD` v2,每条 192 字节)→ `Get2`。 -1. 写一个只读的扫描工具(Python,放在 `tools/`),解析所有 `.eix`,统计每个包、每种 `compressed_type` 的文件数和字节数。 -2. 按统计结果决定: - - 只有 `NONE/COMPRESS/SECURITY` 的包可以离线解出; - - `PANAMA`/`HYBRIDCRYPT*` 如存在,确认密钥来源:本地的 `Index`/配置,还是服务器 `GC_HYBRIDCRYPT_KEYS`/`SDB` 下发。 - 一次真服登录抓取仅可用于确认格式和密钥来源,不能作为构建或发布依赖;正式方案必须是可重复的运行时密钥链,或经过 - 授权、可重复并有来源清单的重新打包流程,密钥本身不进仓库。 -3. 验证 `Index` 文件列出的包顺序和同名文件的覆盖优先级,与 `CEterPackManager` 一致。 -4. 路径归一化:40250 在 Windows 上大小写不敏感,并把 `d:/ymir work/` 等前缀映射到包内路径。确定统一的小写化规则,扫描大小写冲突。 -5. 移动端交付:决定最终形式(例如解包后重新打成我们自己的 `mtpack`,或直接在设备上读 EPK),给出 Android/iOS 的包体积、首包与按需下载的划分,以及更新方式。`MT_ASSETS` 只是开发期覆盖。 -6. 输出写进本文第 5 节,再决定 2D 的资源根切换方式。 +1. **类型统计**(Index 注册的 136 个包,54,891 条,52,609 个不同路径): + + | 类型 | 文件数 | 存储 | 解码后 | + | --- | --- | --- | --- | + | `NONE` | 20,727 | 737.2 MiB | 737.2 MiB | + | `COMPRESS`(LZO) | 29,812 | 592.9 MiB | 1296.8 MiB | + | `SECURITY`(TEA + LZO) | 4,352 | 2.0 MiB | 6.6 MiB | + | `PANAMA` / `HYBRIDCRYPT*` | **0** | — | — | + + `verify --per-type 1000000` 全部 54,891 条解码成功。每个包的类型分布见 JSON 的 `per_pack`。 +2. **密钥来源**:这个客户端只用编译进客户端的两组 TEA 密钥(`s_adwEterPackKey` 解索引,`s_adwEterPackSecurityKey` 解 + SECURITY 数据)。没有 PANAMA 和 HybridCrypt 条目,所以**不需要服务器下发 IV/密钥**,也不需要真服抓包。 + `CAccountConnector` 的 `RegisterPack(..., abIV)` 和 `GC_HYBRIDCRYPT_KEYS/SDB` 照抄移植即可,但这批资源用不到。 + 移植后的 `EterPack.cpp` 需要这两组常量;按"密钥不进仓库",由构建时从参考树生成一个 gitignore 的头文件,不直接写进源码。 +3. **注册顺序与覆盖**: + - `Index` 第 0 行是 `PACK`,后面是 (目录, 名字) 对,共 124 个名字。 + - 实际存在的包有 136 个:Index 里的包,加上两个 `metin2_patch_dragon_rock*_texcache`,再加 `root`。 + - 缺失的包:106 个 `_texcache`,以及 `locale_{ae,bg,ca,mx,pa,uk,us}`。40250 遇到缺失的包只记日志、继续执行。 + - 目录里有、但没注册的文件,40250 永远不会读到:`Outdoor`、`Psyco`(0 字节)、`outdoorempirebattle1`、两个 `.rdch`。 + `.rdch` 在源码里没有任何引用。 + - 查找由 `CEterFileDict` 完成,它是以 `filename_crc` 为键的 `unordered_multimap`。`GetItem` 在 `equal_range` 里返回第一个 + 同名项。40250 用 VS2022(`ClientVS22`)构建,MSVC STL 的 multi 插入会把新元素放在同键已有元素的后面,所以**先注册的 + 包优先**。`UpdateItem`(后注册的覆盖先注册的)只在带 IV 的注册里用到,这批资源没有。 + - 同名条目共 2,282 个,其中 2,194 个内容不同。主要是 `patch1`/`patch2`/`season3_eu`/`metin2_patch_*` 覆盖后面的 + `zone`、`pc*`、`season2`、`outdoor*`、`icon`。**覆盖顺序会影响画面,必须 1:1。** + - 包里有两种打包器留下的格式: + - Ymir 格式:`data_size` 不小于整个 CLZObject,常补到 256 字节的倍数;`data_crc` 按存储字节算。 + - 另一种格式出现在 `metin2_patch_sd`、`metin2_patch_mineral`、`metin2_patch_mundi` 三个包里:`data_size` 比 CLZObject + 短 16 字节,`data_crc` 按解码后的内容算。 + - 在第二种格式下,40250 的 COMPRESS 条目仍能读出来:`CLZO::Decompress` 按 LZO 头里的长度读,不看 `data_size`,读到 + 映射视图里 `data_size` 之后的字节。但 SECURITY 条目过不了 `Get2` 的 CRC 检查。 + - 结果是 4 个文件在原版里**加载失败**:`npc/mineral2/wait.msa`、`effect/background/metinstone_loop_redblack.mse`、 + `monster2/spider_spawn/{wait.msa,line1.mse}`。第 5 个 `effect/etc/buff/buff_symbol1.mse` 由先注册的 `metin2_patch_eu4` + 提供,不受影响。 + - 移植的读取器要照这个行为实现:按头部长度读,保留 CRC 拒绝。 +4. **路径归一化**: + - `ConvertFileName` 只做两件事:ASCII `tolower`,以及 `\` 转 `/`。CP949 字节原样保留。 + - 包内名字全部已经是小写、只用 `/`;52,609 个路径没有大小写冲突,`filename_crc` 与 `crc32(name)` 全部一致。 + - `d:/ymir work/...` 是包内的真实路径,一共 26,955 个。40250 的相对路径映射已经注释掉,所以调用方必须使用 40250 原样的 + 路径名,不能再用 m2dev `assets/` 的相对路径。 + - 包文件名与 `Index` 大小写不一致,例如 `Etc` 对应磁盘上的 `ETC.eix`。NTFS 下能打开;在移动端和 Linux 上,按包名查找 + 磁盘文件时要做大小写不敏感匹配。 +5. **移动端交付**(已注册包的存储大小共 1337 MiB): + + | 分组 | 大小 | + | --- | --- | + | `metin2_patch_*`/`patch1`/`patch2`/`season3_eu` | 692.7 MiB | + | 核心 `d:/ymir work`(pc/monster/zone/effect/npc/item…)+ icon/property/textureset | 405.7 MiB | + | sound/bgm | 87.6 MiB | + | season1/2 | 71.5 MiB | + | 其他 14 种语言的 locale | 49.9 MiB | + | 地图包 | 25.9 MiB | + | `locale_en` | 3.5 MiB | + | root + uiscript | 0.6 MiB | + + 决定: + - **设备上直接读 EPK**,用移植的 `EterPack`/`EterPackManager` 经 `asset_io` 读取。不重新打包成 `mtpack`。这样覆盖顺序、 + CRC 拒绝等行为天然 1:1,更新时直接替换包文件。 + - 首包包含 Index 里除其他语言外的全部包,约 1.29 GB。其他语言按需下载。 + - patch 包在覆盖链的最前面,按需下载会改变已有文件的内容,所以不做细粒度按需下载。 + - 如果以后要按需下载,任何新到的包都必须**按 Index 顺序重建整个字典**,不能追加到末尾。否则先注册者优先的结果就变了。 + - 更新方式是整包替换 `.eix/.epk`,由我们自己的清单记录版本号和 sha256。 +6. **2D 的资源根切换**: + - 新增 pack 后端:`asset_io` 在 `MT_ASSETS` 之外,接上移植的 `CEterPackManager`,按 `pack/Index` 注册。 + - 渲染和 GDScript 的资源请求改用 40250 路径,经过 `ConvertFileName` 查找。 + - m2dev `assets/` 的散文件保留作开发期覆盖,直到对应读取方切到新路径。 + - 严格门禁(第 5 节步骤 3)的来源清单改为记录各个 `.eix/.epk` 的 sha256。 ### 批次 2P:内嵌 Python 集成 @@ -276,7 +333,7 @@ extension/third_party/cpython-2.7.18/ # 静态库(2P 批次 | `mob_proto` | `proto.cpp` | **读不了**:每条 255 字节 vs 335 字节,加密同上 | 同上 | | `item_list.txt` | `item_list.gd` | 177 行不同(m2dev 有 dummy 条目等);40250 放在 `locale/en/`,m2dev 在 `locale/common/` | 按 40250 路径读取 | | `locale_interface.txt`、`itemdesc.txt`、`skilldesc.txt`、`skilltable.txt` | UI、技能表 | 20 行 / 2 行 / 2 行 / 位置不同 | 按 40250 路径读取 | -| 模型、贴图、地图(PC、Monster、Outdoor 等) | 渲染 | **未核对**:40250 是经典 `.eix/.epk` 包 | 由 2R 决定 | +| 模型、贴图、地图(PC、Monster、Outdoor 等) | 渲染 | 40250 是经典 `.eix/.epk`,136 个包、52,609 个路径,全部可以本地解码(2R) | 移植 `EterPack`/`EterPackManager` 接到 `asset_io`,请求改用 40250 路径(见 2R 第 6 条) | 切换步骤(批次 2D): @@ -288,7 +345,8 @@ extension/third_party/cpython-2.7.18/ # 静态库(2P 批次 - 为 40250 的 root、locale、proto 和关键包生成来源清单(路径 + sha256),提交到 `audit/`,测试开始时核对。 4. 新增并在 CI/发布前强制运行 `script/run_40250_asset_gate.sh`;它设置/校验 `MT_ASSETS_STRICT=1`,运行 native proto/pack/formats 测试和 Godot root/locale/render 测试。脚本在没有 40250 资源时必须失败,不能把跳过报告成 PASS。 -5. 资源根切换方式按 2R 的结论执行,跑严格门禁,更新本节状态。 +5. 资源根切换按 2R 第 6 条执行:移植 `EterPack`(`CLZObject`/TEA 已经在第 2 步)和 `EterPackManager`,接上 + `asset_io` 的 pack 后端,读取方逐个改用 40250 路径,跑严格门禁,更新本节状态。 ## 6. 进度与 port-map 基线 diff --git a/tools/epk_scan/epk_scan.py b/tools/epk_scan/epk_scan.py new file mode 100644 index 00000000..e08084ac --- /dev/null +++ b/tools/epk_scan/epk_scan.py @@ -0,0 +1,450 @@ +#!/usr/bin/env python3 +"""Read-only scan of the 40250 client packs (batch 2R, docs/PORT-PLAN.md). + +Follows the 40250 read chain: `UserInterface.cpp` PackInitialize (pack/Index), `CEterPackManager` +(RegisterPack, the `_texcache` companion, the root pack, `ConvertFileName`), `CEterPack::__BuildIndex` +(.eix: plain `EPKD` or an `MCOZ` TEA+LZO object) and `CEterPack::Get2` (per-entry compressed_type). + + epk_scan.py summary [--json OUT] per-pack / per-type counts and bytes, registration, overrides, paths + epk_scan.py verify [--per-type N] decode N sample entries of every (pack, type) that needs no server key + +The index and security keys are read from the reference `EterPack/EterPack.cpp` at run time; nothing +key-like is stored here. Nothing is written except the optional --json report. +""" + +from __future__ import annotations + +import argparse +import json +import os +import re +import struct +import sys +import zlib +from collections import Counter, defaultdict +from pathlib import Path + +REPO = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(REPO / ".agents/skills/metin2-40250-parity-audit/scripts")) +import refroot # noqa: E402 + +TYPE_NAMES = {0: "NONE", 1: "COMPRESS", 2: "SECURITY", 3: "PANAMA", 4: "HYBRIDCRYPT", 5: "HYBRIDCRYPT_WITHSDB"} +# Types CEterPack::Get2 can decode with keys compiled into the client; 3 needs the IV from the server +# (CAccountConnector RegisterPack(..., abIV)), 4/5 need GC_HYBRIDCRYPT_KEYS / GC_HYBRIDCRYPT_SDB. +LOCAL_TYPES = {0, 1, 2} +EPKD = b"EPKD" +MCOZ = b"MCOZ" +# TEterPackIndex, #pragma pack(4), 32-bit long: id, filename[161], crc, real_data_size, data_size, data_crc, +# data_position, compressed_type. Only data_size (the stored bytes) is read by CEterPack::Get2; real_data_size is +# packer bookkeeping (free-block reuse) and is smaller than data_size in ~half the COMPRESS entries here, so it is +# ignored. The decoded size lives only in the CLZObject header of COMPRESS/SECURITY data. +INDEX = struct.Struct(" dict[str, tuple[int, ...]]: + text = (ref / "EterPack/EterPack.cpp").read_bytes().decode("latin-1") + keys = {} + for name in ("s_adwEterPackKey", "s_adwEterPackSecurityKey"): + m = re.search(name + r"\[\]\s*=\s*\{([^}]*)\}", text) + if not m: + raise SystemExit(f"{name} not found in reference EterPack.cpp") + keys[name] = tuple(int(v) for v in re.findall(r"\d+", m.group(1))) + return keys + + +def tea_decrypt(data: bytes, key: tuple[int, ...]) -> bytes: + """EterBase/tea.cpp tea_decrypt: 32 rounds, DELTA 0x9E3779B9, 32-bit words, size rounded up to 8.""" + if len(data) % 8: + data = data + bytes(8 - len(data) % 8) + words = struct.unpack(f"<{len(data) // 4}I", data) + out = [] + k0, k1, k2, k3 = key + kk = (k0, k1, k2, k3) + M = 0xFFFFFFFF + for i in range(0, len(words), 2): + y, z = words[i], words[i + 1] + s = (0x9E3779B9 * 32) & M + for _ in range(32): + z = (z - ((((y << 4) ^ (y >> 5)) + y) ^ (s + kk[(s >> 11) & 3]))) & M + s = (s - 0x9E3779B9) & M + y = (y - ((((z << 4) ^ (z >> 5)) + z) ^ (s + kk[s & 3]))) & M + out += (y, z) + return struct.pack(f"<{len(out)}I", *out) + + +def lzo1x_decompress(src: bytes, out_len: int) -> bytes: + """lzo1x_decompress (lzo1x_d.ch), the decoder 40250 calls in CLZObject::Decompress.""" + op = bytearray() + ip = 0 + + def copy_match(dist_back: int, n: int) -> None: + start = len(op) - dist_back + if start < 0: + raise ValueError("lzo: match before start") + if dist_back >= n: + op.extend(op[start:start + n]) + else: + for j in range(n): + op.append(op[start + j]) + + state = "loop" + t = 0 + if src[0] > 17: + t = src[0] - 17 + ip = 1 + if t < 4: + state = "match_next" + else: + op += src[ip:ip + t] + ip += t + state = "first_literal_run" + while True: + if state == "loop": + t = src[ip]; ip += 1 + if t >= 16: + state = "match" + continue + if t == 0: + while src[ip] == 0: + t += 255; ip += 1 + t += 15 + src[ip]; ip += 1 + op += src[ip:ip + t + 3] + ip += t + 3 + state = "first_literal_run" + if state == "first_literal_run": + t = src[ip]; ip += 1 + if t >= 16: + state = "match" + else: + dist = 1 + 0x0800 + (t >> 2) + (src[ip] << 2); ip += 1 + copy_match(dist, 3) + state = "match_done" + if state == "match": + if t >= 64: + dist = 1 + ((t >> 2) & 7) + (src[ip] << 3); ip += 1 + copy_match(dist, (t >> 5) + 1) + elif t >= 32: + t &= 31 + if t == 0: + while src[ip] == 0: + t += 255; ip += 1 + t += 31 + src[ip]; ip += 1 + dist = 1 + (src[ip] >> 2) + (src[ip + 1] << 6); ip += 2 + copy_match(dist, t + 2) + elif t >= 16: + dist = (t & 8) << 11 + t &= 7 + if t == 0: + while src[ip] == 0: + t += 255; ip += 1 + t += 7 + src[ip]; ip += 1 + dist += (src[ip] >> 2) + (src[ip + 1] << 6); ip += 2 + if dist == 0: + break # end of stream + copy_match(dist + 0x4000, t + 2) + else: + dist = 1 + (t >> 2) + (src[ip] << 2); ip += 1 + copy_match(dist, 2) + state = "match_done" + if state == "match_done": + t = src[ip - 2] & 3 + if t == 0: + state = "loop" + continue + state = "match_next" + if state == "match_next": + op += src[ip:ip + t] + ip += t + t = src[ip]; ip += 1 + state = "match" + if len(op) != out_len: + raise ValueError(f"lzo: size {len(op)} != {out_len}") + return bytes(op) + + +def lz_object(buf: bytes, key: tuple[int, ...] | None) -> bytes: + """CLZO::Decompress: THeader {fourcc, encrypt, compressed, real}, then MCOZ + LZO (TEA'd when encrypt != 0).""" + fourcc, enc, comp, real = struct.unpack_from("<4sIII", buf) + if fourcc != MCOZ: + raise ValueError("not an MCOZ object") + if enc: + if key is None: + raise ValueError("encrypted object without key") + body = tea_decrypt(buf[16:16 + enc], key) + if body[:4] != MCOZ: + raise ValueError("TEA key incorrect") + return lzo1x_decompress(body[4:4 + comp], real) + return lzo1x_decompress(buf[20:20 + comp], real) + + +# ---------------------------------------------------------------- packs + +def read_registration(pack_dir: Path) -> list[tuple[str, str]]: + """PackInitialize: line 0 is PACK/FILE, then (folder, name) pairs for i in [1, count-1).""" + lines = (pack_dir / "Index").read_bytes().decode("latin-1").splitlines() + if lines[0] not in ("PACK", "FILE"): + raise SystemExit("pack/Index: first line must be PACK or FILE") + pairs = [] + for i in range(1, len(lines) - 1, 2): + pairs.append((lines[i], lines[i + 1])) + return pairs + + +def read_index(eix: Path, key: tuple[int, ...]) -> tuple[list[dict], bool]: + raw = eix.read_bytes() + encrypted = raw[:4] == MCOZ + data = lz_object(raw, key) if encrypted else raw + if data[:4] != EPKD: + raise ValueError(f"{eix.name}: fourcc {data[:4]!r}") + ver, count = struct.unpack_from(" Path | None: + """40250 runs on case-insensitive NTFS: `Etc` in Index opens `ETC.eix`.""" + want = (stem + ext).lower() + for p in pack_dir.iterdir(): + if p.name.lower() == want: + return p + return None + + +def load(client: Path, ref: Path): + pack_dir = client / "pack" + keys = reference_keys(ref) + order = [] # (registered name, folder) in CEterPackManager insertion order + for folder, name in read_registration(pack_dir): + order.append((name, folder)) + order.append((name + "_texcache", folder)) + order.append(("root", "")) # RegisterRootPack after the loop + packs = {} + for name, folder in order: + if name in packs: + continue # m_PackMap.find: registered once + eix, epk = find_file(pack_dir, name, ".eix"), find_file(pack_dir, name, ".epk") + if not eix or not epk: + packs[name] = {"folder": folder, "missing": True} + continue + entries, encrypted = read_index(eix, keys["s_adwEterPackKey"]) + packs[name] = {"folder": folder, "missing": False, "eix": eix, "epk": epk, "index_encrypted": encrypted, + "entries": entries, "epk_bytes": epk.stat().st_size} + known = {p.name.lower() for n, v in packs.items() if not v["missing"] for p in (v["eix"], v["epk"])} + stray = sorted(p.name for p in pack_dir.iterdir() if p.name.lower() not in known and p.name != "Index") + return pack_dir, keys, order, packs, stray + + +# ---------------------------------------------------------------- commands + +def decoded_sizes(p: dict) -> dict[int, int]: + """data_position -> decoded bytes (CLZObject THeader.dwRealSize for types 1/2, data_size otherwise).""" + out = {} + with open(p["epk"], "rb") as f: + for e in p["entries"]: + if e["type"] in (1, 2): + f.seek(e["pos"]) + fourcc, _, _, real = struct.unpack("<4sIII", f.read(16)) + if fourcc != MCOZ: + raise ValueError(f"{e['name']}: no MCOZ header") + out[e["pos"]] = real + else: + out[e["pos"]] = e["size"] + return out + + +def summary(args) -> dict: + client = Path(args.client) if args.client else refroot.reference_root().parents[1] / "Client" + pack_dir, keys, order, packs, stray = load(client, refroot.reference_root()) + + per_type = defaultdict(lambda: {"files": 0, "stored_bytes": 0, "decoded_bytes": 0}) + per_pack = {} + crc_mismatch = 0 + not_lower = 0 + backslash = 0 + prefixes = Counter() + dict_first: dict[str, tuple[str, dict]] = {} + overridden = [] + for name, _ in order: + p = packs[name] + if p["missing"] or name in per_pack: + continue + counts = Counter() + size = Counter() + decoded = decoded_sizes(p) + for e in p["entries"]: + t = TYPE_NAMES.get(e["type"], str(e["type"])) + counts[t] += 1 + size[t] += e["size"] + agg = per_type[t] + agg["files"] += 1 + agg["stored_bytes"] += e["size"] + agg["decoded_bytes"] += decoded[e["pos"]] + fn = e["name"] + if zlib.crc32(fn.encode("latin-1")) != e["crc"]: + crc_mismatch += 1 + if any("A" <= c <= "Z" for c in fn): # ConvertFileName is an ASCII tolower; cp949 bytes pass through + not_lower += 1 + if "\\" in fn: + backslash += 1 + prefixes[fn.split("/")[0] + "/" + (fn.split("/")[1] + "/" if fn.count("/") > 1 else "")] += 1 + # CEterFileDict::InsertItem into an unordered_multimap; GetItem returns the first equal element. + if fn in dict_first: + w_pack, w = dict_first[fn] + same = (w["size"], w["data_crc"]) == (e["size"], e["data_crc"]) + overridden.append({"file": fn, "kept": w_pack, "shadowed": name, "same_content": same}) + else: + dict_first[fn] = (name, e) + per_pack[name] = {"folder": p["folder"], "files": sum(counts.values()), "epk_bytes": p["epk_bytes"], + "index_encrypted": p["index_encrypted"], "types": dict(counts), "type_bytes": dict(size)} + + lower_groups = defaultdict(set) + for fn in dict_first: + lower_groups[fn.lower()].add(fn) + case_collisions = sorted(v for v in (sorted(g) for g in lower_groups.values()) if len(v) > 1) + + report = { + "pack_dir": "Client/pack", # relative to the 40250 tree; no machine paths in committed reports + "registered": [n for n, _ in order], + "missing": sorted(n for n, v in packs.items() if v["missing"]), + "stray_files": stray, + "per_type": dict(per_type), + "per_pack": per_pack, + "unique_files": len(dict_first), + "overrides": {"count": len(overridden), "different_content": sum(1 for o in overridden if not o["same_content"]), + "by_pair": Counter(f'{o["kept"]} > {o["shadowed"]}' for o in overridden).most_common(), + "different_examples": [o for o in overridden if not o["same_content"]][:20]}, + "paths": {"filename_crc_mismatch": crc_mismatch, "not_lowercase": not_lower, "backslash": backslash, + "case_collisions": case_collisions, "top_prefixes": prefixes.most_common(25)}, + } + print_summary(report) + if args.json: + Path(args.json).write_text(json.dumps(report, indent=1, ensure_ascii=False) + "\n", encoding="utf-8") + print(f"\nwrote {args.json}") + return report + + +def print_summary(r: dict) -> None: + print(f"pack dir: {r['pack_dir']} (40250 tree)") + print(f"registered: {len(r['registered'])} (Index pairs x2 with _texcache, + root); " + f"missing on disk: {len(r['missing'])}; stray files: {r['stray_files']}") + print("\nper compressed_type:") + for t, v in sorted(r["per_type"].items()): + print(f" {t:<22} {v['files']:>7} files {v['stored_bytes'] / 2**20:>9.1f} MiB stored" + f" {v['decoded_bytes'] / 2**20:>9.1f} MiB decoded") + print(f"\nunique paths: {r['unique_files']}; overridden entries: {r['overrides']['count']}" + f" ({r['overrides']['different_content']} with different content)") + for pair, n in r["overrides"]["by_pair"][:15]: + print(f" {n:>6} {pair}") + p = r["paths"] + print(f"\npaths: crc mismatch {p['filename_crc_mismatch']}, not lowercase {p['not_lowercase']}, " + f"backslash {p['backslash']}, case collisions {len(p['case_collisions'])}") + for pre, n in p["top_prefixes"][:12]: + print(f" {n:>7} {pre}") + print("\nper pack (types):") + for name, v in r["per_pack"].items(): + ts = ", ".join(f"{t}={n}" for t, n in sorted(v["types"].items())) + print(f" {name:<40} {v['folder']:<14} {v['files']:>6} {v['epk_bytes'] / 2**20:>7.1f} MiB {ts}") + + +def lz_object_len(head: bytes) -> int: + """Bytes a CLZObject really occupies: THeader + (TEA block when encrypted, else fourcc + LZO).""" + fourcc, enc, comp, _ = struct.unpack_from("<4sIII", head) + if fourcc != MCOZ: + raise ValueError("no MCOZ header") + return 16 + (enc if enc else 4 + comp) + + +def verify(args) -> int: + """Decode every sampled NONE/COMPRESS/SECURITY entry and classify how its pack was written. + + Two packers wrote these files. The Ymir one stores data_size >= the whole CLZObject (often padded to a + 256-byte multiple) and data_crc over the stored bytes, which is what CEterPack::Get2 checks for SECURITY/PANAMA. The other ("short") stores + data_size 16 bytes short of the object and data_crc over the decoded bytes: 40250 still decodes its + COMPRESS entries (CLZO::Decompress trusts the header and reads past data_size inside the mapped view) but + rejects its SECURITY entries in the CRC check, so those files never load in the original client. + """ + client = Path(args.client) if args.client else refroot.reference_root().parents[1] / "Client" + _, keys, _, packs, _ = load(client, refroot.reference_root()) + ok = bad = 0 + layout = Counter() + crc_stats = Counter() + rejected = [] + for name, p in packs.items(): + if p["missing"]: + continue + by_type = defaultdict(list) + for e in p["entries"]: + by_type[e["type"]].append(e) + with open(p["epk"], "rb") as f: + for t, es in sorted(by_type.items()): + if t not in LOCAL_TYPES: + continue + es = sorted(es, key=lambda e: e["size"]) + picks = {id(e): e for e in (es[:: max(1, len(es) // args.per_type)][: args.per_type] + [es[-1]])} + for e in picks.values(): + try: + f.seek(e["pos"]) + stored = f.read(e["size"]) + if len(stored) != e["size"]: + raise ValueError("short read") + if t == 0: + out, kind = stored, "none" + else: + f.seek(e["pos"]) + obj = f.read(lz_object_len(stored[:16])) + gap = len(obj) - e["size"] + kind = "ymir" if gap <= 0 else "short" if gap == 16 else None + if kind is None: + raise ValueError(f"data_size {e['size']} vs object {len(obj)}") + out = lz_object(obj, keys["s_adwEterPackSecurityKey"] if t == 2 else None) + # Only SECURITY/PANAMA are CRC-checked by the client; tally what the CRC covers. + crc = e["data_crc"] + covers = ("stored" if zlib.crc32(stored) == crc else + "decoded" if zlib.crc32(out) == crc else "neither") + layout[f"{name}:{kind}"] += 1 + crc_stats[(TYPE_NAMES[t], kind, covers)] += 1 + if t == 2 and zlib.crc32(stored) != e["data_crc"]: + rejected.append(f"{name}:{e['name']}") + ok += 1 + except (ValueError, IndexError, struct.error) as exc: + bad += 1 + print(f"FAIL {name}:{e['name']} ({TYPE_NAMES[t]}): {exc}") + short = sorted({k.split(":")[0] for k in layout if k.endswith(":short")}) + print(f"short-layout packs: {short}") + print("data_crc covers (type, layout, covers): " + ", ".join(f"{k}={v}" for k, v in sorted(crc_stats.items()))) + print(f"SECURITY entries the 40250 CRC check rejects: {len(rejected)}") + for r in rejected: + print(f" {r}") + print(f"verify: {ok} decoded, {bad} failed") + return 1 if bad else 0 + + +def main() -> int: + ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("--client", default=os.environ.get("MT_40250_CLIENT"), + help="40250 Client dir (default: /../../Client)") + sub = ap.add_subparsers(dest="cmd", required=True) + s = sub.add_parser("summary") + s.add_argument("--json") + v = sub.add_parser("verify") + v.add_argument("--per-type", type=int, default=3) + args = ap.parse_args() + if args.cmd == "summary": + summary(args) + return 0 + return verify(args) + + +if __name__ == "__main__": + sys.exit(main())