diff --git a/mkpfs/pfs.py b/mkpfs/pfs.py index 6ed986f..3c2d74c 100755 --- a/mkpfs/pfs.py +++ b/mkpfs/pfs.py @@ -923,7 +923,7 @@ class PFSCHeader: logical_block_size: Logical PFSC block size. block_offsets_offset: Offset to the block offset table from payload start. data_offset: Absolute offset where PFSC block data begins. - data_length: Logical padded byte length managed by the PFSC stream. + data_length: Logical original byte length managed by the PFSC stream. """ magic: int @@ -1087,6 +1087,7 @@ def encode_pfsc_payload( offsets: list[int] = [header_size] for block in encoded_blocks: offsets.append(offsets[-1] + len(block)) + raw_length: int = len(raw) header: PFSCHeader = PFSCHeader( magic=consts.PFSC_MAGIC, unk4=consts.PFSC_UNK4, @@ -1094,7 +1095,7 @@ def encode_pfsc_payload( logical_block_size=logical_block_size, block_offsets_offset=consts.PFSC_BLOCK_OFFSETS_OFFSET, data_offset=header_size, - data_length=block_count * logical_block_size, + data_length=raw_length, ) header_area: bytearray = bytearray(header_size) struct.pack_into( @@ -1486,7 +1487,7 @@ def _encode_pfsc_into_handle( logical_block_size=logical_block_size, block_offsets_offset=consts.PFSC_BLOCK_OFFSETS_OFFSET, data_offset=header_size, - data_length=block_count * logical_block_size, + data_length=raw_size, ) header_area: bytearray = bytearray(header_size) struct.pack_into( @@ -2076,8 +2077,6 @@ def _parse_pfsc_header(head: bytes) -> tuple[int, int, int, int, int]: raise ValueError("PFSC block size mismatch between block_sz and block_sz2") if logical_size < 0: raise ValueError("PFSC logical size is negative") - if logical_size % logical_block_size != 0: - raise ValueError("PFSC logical size is not aligned to the logical block size") if block_offsets_offset < consts.PFSC_HEADER_SIZE: raise ValueError("PFSC block offset table overlaps header") if block_offsets_offset != consts.PFSC_BLOCK_OFFSETS_OFFSET: @@ -2088,7 +2087,7 @@ def _parse_pfsc_header(head: bytes) -> tuple[int, int, int, int, int]: if data_offset < consts.PFSC_INITIAL_DATA_OFFSET: raise ValueError("PFSC data offset is smaller than the minimum compatible header span") - block_count: int = logical_size // logical_block_size + block_count: int = ceil_div(logical_size, logical_block_size) if logical_size > 0 else 0 return logical_block_size, block_count, block_offsets_offset, data_offset, logical_size @@ -2157,16 +2156,19 @@ def decode_pfsc_payload(payload: bytes, expected_logical_size: int | None = None stored_block: bytes = payload[offsets[idx] : offsets[idx + 1]] logical_out.extend(_decode_pfsc_block(stored_block, logical_block_size, idx)) + padded_logical_size: int = block_count * logical_block_size logical_payload: bytes = bytes(logical_out) - if len(logical_payload) != logical_size: - raise ValueError(f"PFSC logical output size {len(logical_payload)} does not match header size {logical_size}") + if len(logical_payload) != padded_logical_size: + raise ValueError( + f"PFSC logical output size {len(logical_payload)} does not match padded size {padded_logical_size}" + ) if expected_logical_size is not None: if expected_logical_size < 0: raise ValueError("expected inode logical size is negative") if expected_logical_size > logical_size: raise ValueError(f"PFSC logical size {logical_size} is smaller than inode size {expected_logical_size}") return logical_payload[:expected_logical_size] - return logical_payload + return logical_payload[:logical_size] def decode_inode_payload( diff --git a/tests/mkpfs/test_pfs.py b/tests/mkpfs/test_pfs.py index e094fca..024063e 100644 --- a/tests/mkpfs/test_pfs.py +++ b/tests/mkpfs/test_pfs.py @@ -407,6 +407,57 @@ def test_compressed_images_store_direct_pfsc_payloads(self) -> None: assert len(compressed_payload) == compressed_inode.size assert compressed_payload[:4] == b"PFSC" + def test_compressed_image_pfsc_header_tracks_non_aligned_file_size(self) -> None: + """Compressed image PFSC headers should store exact file sizes, not padded block spans.""" + tmp_path: Path = self.make_temp_path() + src: Path = make_app_with_nested_dirs(tmp_path / "src") + non_aligned_file: Path = src / "data" / "non_aligned.bin" + raw: bytes = (b"A" * c.PFSC_LOGICAL_BLOCK_SIZE) + (b"B" * 1024) + non_aligned_file.write_bytes(raw) + out: Path = tmp_path / "non-aligned-pfsc.ffpfs" + build_pfs( + source_root=src, + output_path=out, + block_size=65536, + pfs_version=c.PFS_VERSION_PS4, + inode_bits=32, + case_insensitive=True, + signed=False, + compress=True, + threshold_gain=1, + cpu_count=1, + zlib_level=9, + dry_run=False, + verbose=False, + encrypted=False, + ) + + with out.open("rb") as fh: + header: pfs_mod.ParsedHeader = parse_image_header(fh) + inspection: pfs_mod.PFSImageInspection = inspect_pfs_image(image=out, source=src) + assert inspection.errors == [] + compressed_inode: pfs_mod.ParsedInode = inspection.inodes[inspection.file_inodes["data/non_aligned.bin"]] + assert compressed_inode.is_compressed + compressed_payload: bytes = pfs_mod.read_image_inode_payload(fh, header, compressed_inode) + assert compressed_payload[:4] == b"PFSC" + + _logical_block_size: int + block_count: int + _block_offsets_offset: int + _data_offset: int + logical_size: int + ( + _logical_block_size, + block_count, + _block_offsets_offset, + _data_offset, + logical_size, + ) = pfs_mod._parse_pfsc_header(compressed_payload) + assert logical_size == len(raw) + assert block_count == 2 + assert logical_size != block_count * c.PFSC_LOGICAL_BLOCK_SIZE + assert pfs_mod.decode_pfsc_payload(payload=compressed_payload) == raw + def test_build_pfs_uses_custom_temp_folder_for_pfsc_spool_files(self) -> None: """Compressed builds should route PFSC spool files through the configured temp folder.""" tmp_path: Path = self.make_temp_path() @@ -1024,7 +1075,38 @@ def test_pfsc_encode_decode_round_trip(self) -> None: assert block_size2 == c.PFSC_LOGICAL_BLOCK_SIZE assert block_offsets == c.PFSC_BLOCK_OFFSETS_OFFSET assert data_start >= c.PFSC_INITIAL_DATA_OFFSET - assert data_length == 3 * c.PFSC_LOGICAL_BLOCK_SIZE + assert data_length == len(raw) + + def test_pfsc_header_uses_exact_non_aligned_logical_size(self) -> None: + """PFSC headers should preserve the exact logical size for non-aligned files.""" + raw: bytes = (b"A" * c.PFSC_LOGICAL_BLOCK_SIZE) + (b"B" * 1024) + encoded: bytes + _gain_pct: float + _hypothetical_size: int + encoded, _gain_pct, _hypothetical_size = pfs_mod.encode_pfsc_payload( + raw=raw, + threshold_gain=1, + zlib_level=9, + logical_block_size=c.PFSC_LOGICAL_BLOCK_SIZE, + ) + + assert encoded[:4] == b"PFSC" + _logical_block_size: int + block_count: int + _block_offsets_offset: int + _data_offset: int + logical_size: int + ( + _logical_block_size, + block_count, + _block_offsets_offset, + _data_offset, + logical_size, + ) = pfs_mod._parse_pfsc_header(encoded) + assert logical_size == len(raw) + assert block_count == 2 + assert logical_size != block_count * c.PFSC_LOGICAL_BLOCK_SIZE + assert pfs_mod.decode_pfsc_payload(payload=encoded) == raw def test_pfsc_encode_reports_incremental_progress_bytes(self) -> None: """PFSC encoding should report raw bytes as each logical block is processed."""