# Copyright 2019 RoadrunnerWMC
#
# This file is part of ndspy.
#
# ndspy is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# ndspy is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with ndspy. If not, see <https://www.gnu.org/licenses/>.
"""
Support for filename tables in ROMs and NARCs.
"""
import struct
from . import _common
[docs]class Folder:
"""
A single folder within a filename table, or an entire filename
table.
"""
def __init__(self, folders=None, files=None, firstID=0):
if folders is not None:
self.folders = folders
else:
self.folders = []
if files is not None:
self.files = files
else:
self.files = []
self.firstID = firstID
def __iter__(self):
raise ValueError('Sorry, a Folder is not iterable.')
def __getitem__(self, key):
"""
Convenience function:
- for an integer key, calls filenameOf()
- for a string key:
- calls idOf() if key refers to a file, or
- calls subfolder() if key refers to a directory.
"""
if isinstance(key, int):
fn = self.filenameOf(key)
if fn is not None:
return fn
elif isinstance(key, str):
fileID = self.idOf(key)
if fileID is None:
sbf = self.subfolder(key)
if sbf is not None:
return sbf
else:
return fileID
else:
raise TypeError('Folders can only convert between strings'
f' and ints, not "{type(key)}".')
raise KeyError(f'Unknown key: {key}')
def __contains__(self, key):
try:
self.__getitem__(key)
return True
except Exception:
return False
[docs] def idOf(self, path):
"""
Find the file ID for the given filename, or for the given file
path (using "/" as the separator) relative to this folder.
"""
def findInFolder(requestedPath, searchFolder):
"""
Attempt to find filename in the given folder.
pathSoFar is the path up through this point, as a list.
"""
pathPart = requestedPath[0]
if len(requestedPath) == 1:
# It's hopefully a file in this folder.
if pathPart in searchFolder.files:
# Yay!
return searchFolder.firstID + searchFolder.files.index(pathPart)
else:
# Not here.
return None
# Hopefully we have the requested subfolder...
for subfolderName, subfolder in searchFolder.folders:
if subfolderName == pathPart:
# Yup.
return findInFolder(requestedPath[1:], subfolder)
# Welp.
return None
pathList = path.split('/')
while not pathList[-1]: pathList = pathList[:-1]
while not pathList[0]: pathList = pathList[1:]
return findInFolder(pathList, self)
[docs] def subfolder(self, path):
"""
Find the Folder instance for the given subfolder name, or for
the given folder path (using "/" as the separator) relative to
this folder.
"""
def findInFolder(requestedPath, searchFolder):
"""
Attempt to find filename in the given folder.
pathSoFar is the path up through this point, as a list.
"""
pathPart = requestedPath[0]
for subfolderName, subfolder in searchFolder.folders:
if subfolderName == pathPart:
if len(requestedPath) == 1:
# Found the actual folder that was requested!
return subfolder
else:
# Search another level down
return findInFolder(requestedPath[1:], subfolder)
# Welp.
return None
pathList = path.split('/')
while not pathList[-1]: pathList = pathList[:-1]
while not pathList[0]: pathList = pathList[1:]
return findInFolder(pathList, self)
[docs] def filenameOf(self, id):
"""
Find the filename of the file with the given ID. If it exists
in a subfolder, the filename will be returned as a path
separated by "/"s.
"""
def findInFolder(pathSoFar, searchFolder):
"""
Attempt to find id in the given folder.
pathSoFar is the path up through this point, as a list.
"""
# Check if it's in this folder
firstID = searchFolder.firstID
if firstID <= id < firstID + len(searchFolder.files):
# Found it!
filename = searchFolder.files[id - firstID]
return [*pathSoFar, filename]
# Check subfolders
for subfolderName, subfolder in searchFolder.folders:
result = findInFolder([*pathSoFar, subfolderName], subfolder)
if result is not None:
# Found it in that folder!
return result
# Otherwise, keep checking other subfolders.
# Didn't find it.
return None
result = findInFolder([], self)
if result is not None:
return '/'.join(result)
else:
return None
def _strListUncombined(self, indent=0, fileList=None):
"""
Return a list of (line, preview) pairs, where line is a whole
printout line except for the preview, and preview is the
preview. This lets _strList pad the previews to all fall in the
same column.
"""
L = []
indentStr = ' ' * (indent + 1)
# Print filenames first, since those have file IDs less than
# those of files contained in subfolders
for i, fileName in enumerate(self.files):
fid = self.firstID + i
if fileList is None or fid >= len(fileList):
preview = None
else:
preview = _common.shortBytesRepr(fileList[fid], 0x10)
L.append((f'{fid:04d}' + indentStr + fileName, preview))
for folderName, folder in self.folders:
L.append((f'{folder.firstID:04d}' + indentStr + folderName + '/', None))
L.extend(folder._strListUncombined(indent + 4, fileList))
return L
def _strList(self, indent=0, fileList=None):
"""
Return a list of lines that could be useful for a printout of
the folder. fileList can be used to add previews of files.
Even though this is an internal function, other ndspy modules
(narc, for one) call it directly, so be careful if you change
it!
"""
strings = []
uncombined = self._strListUncombined(indent, fileList)
if uncombined:
previewColumn = max(len(entry[0]) for entry in uncombined) + 4
else:
previewColumn = 4
for line, preview in uncombined:
if preview is not None:
line += ' ' * (previewColumn - len(line))
line += preview
strings.append(line)
return strings
def __str__(self):
return '\n'.join(self._strList())
def __repr__(self):
return (f'{type(self).__name__}({self.folders!r}'
f', {self.files!r}'
f', {self.firstID!r})')
[docs]def load(fnt):
"""
Create a Folder from filename table data. This is the inverse of
save().
"""
def loadFolder(folderId):
"""
Load the folder with ID `folderId` and return it as a Folder.
"""
folderObj = Folder()
# Get the entries table offset and file ID from the top of the
# fnt file
off = 8 * (folderId & 0xFFF)
entriesTableOff, fileID = struct.unpack_from('<IH', fnt, off)
folderObj.firstID = fileID
off = entriesTableOff
# Read file and folder entries from the entries table
while True:
control, = struct.unpack_from('B', fnt, off); off += 1
if control == 0:
break
# That first byte is a control byte that includes the length
# of the upcoming string and if this entry is a folder
len_, isFolder = control & 0x7F, control & 0x80
name = fnt[off : off+len_].decode('latin-1'); off += len_
if isFolder:
# There's an additional 2-byte value with the subfolder
# ID. Get that and load the folder
subFolderID, = struct.unpack_from('<H', fnt, off); off += 2
folderObj.folders.append((name, loadFolder(subFolderID)))
else:
folderObj.files.append(name)
return folderObj
# Root folder is always 0xF000
return loadFolder(0xF000)
[docs]def save(root):
"""
Generate a bytes object representing this root folder as a filename
table. This is the inverse of load().
"""
# folderEntries is a dict of tuples:
# {
# folderID: (initialFileID, parentFolderID, b'file entries data'),
# folderID: (initialFileID, parentFolderID, b'file entries data'),
# }
# This is an intermediate representation of the filenames data that
# can be converted to the final binary representation much more
# easily than the nested lists can.
folderEntries = {}
# nextFolderID allows us to assign folder IDs in sequential order.
# The root folder always has ID 0xF000.
nextFolderID = 0xF000
def parseFolder(d, parentID):
"""
Parse a Folder and add its entries to folderEntries.
`parentID` is the ID of the folder containing this one.
"""
# Grab the next folder ID
nonlocal nextFolderID
folderID = nextFolderID
nextFolderID += 1
# Create an entries table and add filenames and folders to it
entriesTable = bytearray()
for file in d.files:
# Each file entry is preceded by a 1-byte length value.
# Top bit must be 0 or else it'll be interpreted as a
# folder.
if len(file) > 127:
raise ValueError(f'Filename "{file}" is {len(file)}'
' characters long (maximum is 127)!')
entriesTable.append(len(file))
entriesTable.extend(file.encode('latin-1'))
for folderName, folder in d.folders:
# First, parse the subfolder and get its ID, so we can save
# that to the entries table.
otherID = parseFolder(folder, folderID)
# Folder name is preceded by a 1-byte length value, OR'ed
# with 0x80 to mark it as a folder.
if len(folderName) > 127:
raise ValueError(f'Folder name "{folderName}" is'
f' {len(folderName)} characters long (maximum is'
' 127)!')
entriesTable.append(len(folderName) | 0x80)
entriesTable.extend(folderName.encode('latin-1'))
# And the ID of the subfolder goes after its name, as a
# 2-byte value.
entriesTable.extend(struct.pack('<H', otherID))
# And the entries table needs to end with a null byte to mark
# its end.
entriesTable.extend(b'\0')
folderEntries[folderID] = (d.firstID, parentID, entriesTable)
return folderID
# The root folder's parent's ID is the total number of folders.
def countFoldersIn(folder):
folderCount = 0
for _, f in folder.folders:
folderCount += countFoldersIn(f)
return folderCount + 1
rootParentId = countFoldersIn(root)
# Ensure that the root folder has the proper folder ID.
rootId = parseFolder(root, rootParentId)
assert rootId == 0xF000, f'Root FNT folder has incorrect root folder ID: {hex(rootId)}'
# Allocate space for the folders table at the beginning of the file
fnt = bytearray(len(folderEntries) * 8)
# We need to iterate over the folders in order of increasing ID.
for currentFolderID in sorted(folderEntries.keys()):
fileID, parentID, entriesTable = folderEntries[currentFolderID]
# Add the folder entries to the folder table
offsetInFolderTable = 8 * (currentFolderID & 0xFFF)
struct.pack_into('<IHH', fnt, offsetInFolderTable,
len(fnt), fileID, parentID)
# And tack the folder's entries table onto the end of the file
fnt.extend(entriesTable)
return fnt