How to Expand - thethiny/NRS-Asset-Manager GitHub Wiki
This guide explains how to add support for a new NRS game (e.g., MKX, MK1, IJ1). All NRS games share the same engine lineage, so the structure is consistent.
You need:
- Sample
.xxxfiles from the target game - The correct Oodle DLL version (check the game's install directory)
- A hex editor to inspect headers and compare with known formats
Create the directory structure mirroring existing games:
mk_utils/nrs/<game_code>/
├── __init__.py
├── enums.py # Game-specific enums (compression, pixel formats, etc.)
├── ue3_common.py # Structs: header, archive base, table entries
├── archive.py # UE3Asset class (compressed .xxx parser)
├── midway.py # MidwayAsset class (decompressed format parser)
├── ue3_properties.py # UE3 property deserializer
└── class_handlers/
├── __init__.py # Handler registration
├── database.py # Database export → JSON
└── texture2d.py # Texture2D export → DDS/PNG
Open a .xxx file in a hex editor. The first 4 bytes should be C1 83 2A 9E (magic 0x9E2A83C1).
Read the FPackageFileSummary serialization order from the game or by comparing with known formats. Key fields to identify:
class NewGameAssetHeader(Struct):
_fields_ = [
("magic", c_uint32), # Always 0x9E2A83C1
("file_version", c_uint16), # Identifies the engine version
("licensee_version", c_uint16), # Usually 157
("total_header_size", c_uint32), # Where export data starts
("midway_team_four_cc", c_char * 4), # Game identifier ("MK11", "DCF2", etc.)
# ... remaining fields vary by version
("compression_flag", c_uint32), # Last field before compressed chunks
]Critical: Compare field order and sizes with MK11 (104 bytes) and IJ2 (100 bytes). The header evolved between games — fields may be added, removed, or reordered.
The FCompressedChunk struct defines how data blocks map between compressed and decompressed space. Verify the field sizes — IJ2 uses:
u64 uncompressed_offset, u32 uncompressed_size, u64 compressed_offset, u32 compressed_size
While MK11 uses all u64 fields with named sub-packages.
This is the most version-sensitive struct. The serialization order from MK11:
ClassIndex(4), OuterIndex(4), ObjectName(FName=4+4), SuperIndex(4),
ObjectFlags(8), ObjectGuid(16), PackageName(4), Unk(4),
SerialSize(4), SerialOffset(8), Unk2(8), Unk3(4) = 76 bytes
IJ2:
ClassIndex(4), SuperIndex(4), OuterIndex(4), ObjectName(FName=4+4),
ArchetypeIndex(4), ReferencedObjects(4), ObjectFlags(8), ObjectGuid(16),
SerialSize(4), SerialOffset(8), ComponentMap(variable), ExportFlags(4) = 72+ bytes
Key differences to check:
- Field ORDER (especially name/suffix, super/outer)
- Whether
FNameisu32+u32oru64 - Variable-size fields (ComponentMap in IJ2)
MK11 uses 20 bytes (5 x i32 with resolve_object). IJ2 uses 28 bytes (3 FNames + 1 i32). Check if your game's imports use name indices or resolve_object references.
class NewGameUE3Asset(NewGameArchive):
VERSION_RANGE = range(780, 810) # Accepted file_version range
def __init__(self, path: str, extra_path: str = ""):
super().__init__(path, extra_path)
def parse(self, skip_bulk: bool = False):
self.header = self.parse_header()
self.compression_mode = NewGameCompressionType(self.header.compression_flag)
self.compressor = self.get_compressor(self.compression_mode)
# Parse compressed chunks, filename, etc.
def to_midway(self, skip_bulk: bool = False):
buffer = self._MidwayBuilder.from_asset(self, skip_bulk)
return NewGameMidwayAsset(buffer, self.psf_source)Must implement: parse_header(), parse(), to_midway(), parse_all(), dump()
class NewGameMidwayAsset(NewGameArchive):
def parse(self, resolve=True, skip_bulk=False):
self.parse_summary()
self.file_name = self.parse_file_name()
# Parse file tables if applicable
self.name_table = list(self.parse_name_table())
self.import_table = list(self.parse_uobject_table(...))
self.export_table = list(self.parse_uobject_table(...))
if resolve:
self.resolve_table_info(self.import_table)
self.resolve_table_info(self.export_table)Must implement: parse_summary(), validate_file(), parse(), dump(), parse_and_save_export()
Copy the UE3 property parser from the closest game version and adjust:
- Property size field (u32 vs u64)
- FName serialization (u32+u32 vs u64)
- Known array/map key types
Create handlers following the ClassHandler base class:
class NewGameDatabaseHandler(ClassHandler):
HANDLED_TYPES = {"somedatabasetype"}
def parse(self):
# Parse UE3 properties from self.mm
def save(self, data, export, asset_name, save_dir, instance):
# Write output file# mk_utils/scripts/newgame_extractors.py
def extract_all(files, output_dir="extracted", overwrite=False):
for file_info in files:
asset = NewGameUE3Asset(file_path, extra_path)
midway = asset.parse_all(save_path=output_dir)
for export in midway.export_table:
handler = newgame_handlers.get(export.class_.name.lower())
if handler:
midway.parse_and_save_export(export, handler["handler_class"], output_dir, overwrite)Register the game in main.py's game registry:
GAME_REGISTRY = [
(IJ2UE3Asset, "DCF2", range(700, 750)),
(MK11UE3Asset, "MK11", range(760, 800)),
(NewGameUE3Asset, "NEWG", range(800, 850)),
]Create test files following the existing pattern:
tests/<game_code>/
├── test_<game>_gamedata.py # Test files in gamedata/<game>
├── test_<game>_pass_validation.py # Test with game install files
└── processors/
├── test_database.py
└── test_texture2d.py
- Header struct matches hex dump
- Compressed chunks decompress correctly
- Export table entries parse with correct offsets (chain contiguously)
- Final export table position matches
total_header_size - Name table resolves correctly
- Import/export resolution works (resolve_object)
- UE3 properties parse without errors
- Class handlers produce valid output
- Tests pass for 3+ sample files