using System; using System.Collections.Generic; using System.ComponentModel; using System.IO; using System.Linq; using System.Text.RegularExpressions; using System.Xml.Linq; using JD.Core.ProjectManaging; using JD.LogService; using NUnit.Framework; using Packager.Parameters; using Ubisoft.Collections; namespace Packager.Tasks { [DisplayName("Generate Package Config")] public class GeneratePackageConfigTask : Task { [Input("Required files", IOCardinality.Multi, IOType.File, Flags = InputFlags.Optional)] public ReferenceList RequiredFiles { get; set; } [Input("Optional files", IOCardinality.Multi, IOType.File, Flags = InputFlags.Optional)] public ReferenceList OptionalFiles { get; set; } [Input("Update files", IOCardinality.Multi, IOType.File, Flags = InputFlags.Optional)] public ReferenceList UpdateFiles { get; set; } [Input("Launch files", IOCardinality.Multi, IOType.File, Flags = InputFlags.Optional)] public ReferenceList LaunchFiles { get; set; } [Input("Template file", IOCardinality.Single, IOType.File)] public SingleReference TemplateFile { get; set; } [Input("Package order file", IOCardinality.Single, IOType.File)] public SingleReference PackageOrderFile { get; set; } [Output("Package Config", IOType.File)] public ReferenceList PackageConfig = new ReferenceList(); [Output("Id Table", IOType.File)] public ReferenceList IdTable = new ReferenceList(); [Output("Updated package order", IOType.File)] public ReferenceList UpdatedPackageOrder = new ReferenceList(); private struct PackageOrderSection { public readonly int Begin; public readonly int End; public readonly bool AllowSharedIds; public static int MinValue = 1; public static int MaxValue = 32767; public PackageOrderSection(int b, int e, bool shared) { Begin = b; End = e; AllowSharedIds = shared; } } private Dictionary _packageOrderSections; private Dictionary _fileOrderIndices; private PackageConfigTemplate _template; private int _jdmMapsCommonChunkId; private enum SectionType { DET_UPDATE, DET_REQUIRED, DET_OPTIONAL }; private readonly string[] _sectionNames = { "UpdateFiles", "RequiredFiles", "OptionalFiles" }; // read this from the package order file private const string _nonBundledEntryName = "_NONBUNDLEDFILES_"; public GeneratePackageConfigTask() { RequiredFiles = new ReferenceList(); OptionalFiles = new ReferenceList(); UpdateFiles = new ReferenceList(); TemplateFile = new SingleReference(); PackageOrderFile = new SingleReference(); LaunchFiles = new ReferenceList(); _packageOrderSections = null; _fileOrderIndices = null; _template = null; } public override bool NeedCooker => true; protected override void StartInternal(BuildConfiguration profile, cookerWrapper cooker) { WorkerFunc(profile, cooker, TaskOutputFolder); } protected void WorkerFunc(BuildConfiguration _profile, cookerWrapper _cooker, string _outputFolder) { try { var platform = _profile.PlatformList.First(); if (string.Equals(platform, "Orbis", StringComparison.OrdinalIgnoreCase) || string.Equals(platform, "PS3", StringComparison.OrdinalIgnoreCase) ) { // Generate both SCEA & SCEE GP4 files for Orbis and GP3 files for PS3Q GenerateOutput(_cooker, _outputFolder, platform, "SCEA"); GenerateOutput(_cooker, _outputFolder, platform, "SCEE"); } else { GenerateOutput(_cooker, _outputFolder, platform, null); } } catch (Exception ex) { LogMessage(true, $"System exception: {ex.Message}"); } } private void GenerateOutput(cookerWrapper cooker, string outputFolder, string platform, string sku) { try { string chunkMappingFile = DataPath.GetAbsolutePath(TemplateFile.Value); string packageOrderFile = DataPath.GetAbsolutePath(PackageOrderFile.Value); _template = PackageConfigTemplate.Open(chunkMappingFile, platform, sku); if (OpenPackageOrder(packageOrderFile, platform) && _template != null) { _template.Platform = platform; string destFile = Path.Combine(outputFolder, _template.OutputFilename); using (var file = new StreamWriter(destFile, false)) { ProcessLaunchFiles(); ProcessOptionalFiles(); ProcessRequiredFiles(); ProcessUpdatedFiles(); ProcessOptionalJdmMapFilesOnOrbis(); // order the input by ID var orderedFilenames = new List>(); // enumerate the entries, excluding k_NonBundledEntryName entries var packageOrderOfSection = _fileOrderIndices.Where(id => (! string.Equals(id.Key, _nonBundledEntryName, StringComparison.OrdinalIgnoreCase))); foreach (var existingFile in packageOrderOfSection) { orderedFilenames.Add(new KeyValuePair(existingFile.Value, existingFile.Key)); } orderedFilenames.Sort((a, b) => { return a.Key.CompareTo(b.Key); }); if (string.Equals(platform, "Durango", StringComparison.OrdinalIgnoreCase)) { var durangoTemplate = (PackageConfigTemplateDurango)_template; durangoTemplate.LastRequiredChunk = GetLastRequiredId(); } else if (string.Equals(platform, "Scarlett", StringComparison.OrdinalIgnoreCase)) { var scarlettTemplate = (PackageConfigTemplateScarlett)_template; scarlettTemplate.LastRequiredChunk = GetLastRequiredId(); } else if (string.Equals(platform, "Pc", StringComparison.OrdinalIgnoreCase)) { var pcTemplate = (PackageConfigTemplatePC)_template; pcTemplate.LastRequiredChunk = GetLastRequiredId(); } _template.Process(file, orderedFilenames); PackageConfig.Add(destFile); } var updatedPackageOrderFilename = Path.Combine(outputFolder, "UpdatedPackageOrder.xml"); SaveUpdatedPackageOrder(packageOrderFile, updatedPackageOrderFilename, platform); UpdatedPackageOrder.Add(updatedPackageOrderFilename); var idTableOutFilename = Path.Combine(outputFolder, "IdTable.idt"); { List> sortedIds = _fileOrderIndices.ToList(); sortedIds.Sort((firstPair, nextPair) => firstPair.Value.CompareTo(nextPair.Value)); cooker.generateInstallIdTable(idTableOutFilename, sortedIds); } IdTable.Add(idTableOutFilename); } } catch (Exception ex) { if (ex is PackageConfigException) { HandlePackageConfigException(ex); } else { LogMessage(true, $"System exception: {ex.Message}"); } } } private void HandlePackageConfigException(Exception ex) { var packageConfigException = (PackageConfigException)ex; if (packageConfigException.Fatal) { LogMessage(ex.Message, LogCategory.Error); } else { LogMessage("SKIPPING: " + ex.Message, LogCategory.Warning); } } private void ProcessUpdatedFiles() { LoopAllFiles(UpdateFiles, SectionType.DET_UPDATE); } private void ProcessLaunchFiles() { var input = OptionalFiles.Select(Path.GetFileName).Where(IsJdLaunchFile).OrderBy(Path.GetFileNameWithoutExtension); input.ForEach(AddToRequiredFiles); } private void ProcessRequiredFiles() { LoopAllFiles(RequiredFiles, SectionType.DET_REQUIRED); } private void ProcessOptionalFiles() { var input = OptionalFiles.Where(x => (!IsOrbisJdmMap(Path.GetFileName(x)) && !IsJdLaunchFile(Path.GetFileName(x)))).OrderBy(Path.GetFileNameWithoutExtension); LoopAllFiles(input, SectionType.DET_OPTIONAL); } private void ProcessOptionalJdmMapFilesOnOrbis() { var sec = _packageOrderSections[_sectionNames[(int)SectionType.DET_OPTIONAL]]; var maxValidDatachunkId = sec.End; _jdmMapsCommonChunkId = _fileOrderIndices.Values.Max() + 1; if (_jdmMapsCommonChunkId >= maxValidDatachunkId) { LogMessage("The Common Chunk Id for Jdm Maps exceeds the maximum optional file limit defined in packageorder.xml file", LogCategory.Error); return; } var input = OptionalFiles.Select(Path.GetFileName).Where(IsOrbisJdmMap).OrderBy(Path.GetFileNameWithoutExtension); input.ForEach(AddJdmMapsToCommonChunk); } private void LoopAllFiles(IEnumerable input, SectionType type) { foreach (var inputFile in input) { if (!String.IsNullOrEmpty(inputFile)) { string inputFilename = Path.GetFileName(inputFile); AddPackageOrderEntry(inputFilename, type); } } } #region PackageOrder protected bool OpenPackageOrder(string _filename, string _platform) { try { using (var file = File.OpenText(_filename)) { _packageOrderSections = new Dictionary(StringComparer.InvariantCultureIgnoreCase); _fileOrderIndices = new Dictionary(StringComparer.InvariantCultureIgnoreCase); var packageOrderFile = XElement.Load(file); IEnumerable platformEntries = from elem in packageOrderFile.Elements("Platform") where (elem.Attribute("Name").Value.ToLower() == _platform.ToLower()) select elem; if (!platformEntries.Any()) { throw new PackageConfigException("Current platform not specified in package order", false); } ////////////////////////////////////////////////////////////////////////// var idRangeElem = platformEntries.First().Element("IdRange"); if (idRangeElem == null) throw new PackageConfigException($"PackageOrder for {_platform} contains no IdRange element", true); int? minValue = (int?)idRangeElem.Attribute("Min"); int? maxValue = (int?)idRangeElem.Attribute("Max"); if (minValue == null) throw new PackageConfigException("IdRange definition in package order missing Min attribute", true); if (maxValue == null) throw new PackageConfigException("IdRange definition in package order missing Max attribute", true); PackageOrderSection.MinValue = minValue.GetValueOrDefault(-1); PackageOrderSection.MaxValue = maxValue.GetValueOrDefault(-1); ////////////////////////////////////////////////////////////////////////// IEnumerable sections = from elem in platformEntries.Elements("Section") select elem; foreach (var section in sections) { int? beginValue = (int?)section.Attribute("Begin"); int? endValue = (int?)section.Attribute("End"); bool canShare = (section.Attribute("AllowSharedIds") != null && string.Equals(section.Attribute("AllowSharedIds").Value, "true", StringComparison.OrdinalIgnoreCase)); if (beginValue == null) throw new PackageConfigException("Section definition in package order missing Begin attribute", true); if (endValue == null) throw new PackageConfigException("Section definition in package order missing End attribute", true); _packageOrderSections[section.Attribute("Name").Value] = new PackageOrderSection( beginValue.GetValueOrDefault(PackageOrderSection.MinValue), endValue.GetValueOrDefault(PackageOrderSection.MaxValue), canShare ); } ////////////////////////////////////////////////////////////////////////// var entries = from elem in platformEntries.Elements("File") select elem; foreach (var entry in entries) { var entryName = (string)entry.Attribute("Name"); int? id = (int?)entry.Attribute("Id"); if (entryName == null) throw new PackageConfigException("File entry in package order missing Name attribute", true); if (id == null) throw new PackageConfigException("File entry in package order missing Id attribute", true); _fileOrderIndices[entryName] = id.GetValueOrDefault(-1); } var launchFiles = packageOrderFile.Elements("LaunchFiles").First().Elements("File"); foreach (var launchFile in launchFiles) { var entryName = (string)launchFile.Attribute("Name"); entryName = $"{entryName}_{_platform}.ipk"; LaunchFiles.Add(entryName.ToLower()); } ValidatePackageOrder(_platform); return true; } } catch (DirectoryNotFoundException /*ex*/) { throw new PackageConfigException($"Package order file '{_filename}' not found", true); } catch (FileNotFoundException /*ex*/) { throw new PackageConfigException($"Package order file '{_filename}' not found", true); } catch (FormatException /*ex*/) { } catch (OverflowException /*ex*/) { } return false; } protected void ValidatePackageOrder(string platform) { if (string.Equals(platform, "Durango", StringComparison.OrdinalIgnoreCase) || string.Equals(platform, "Scarlett", StringComparison.OrdinalIgnoreCase)) { ValidatePackageOrderDurango(); } else if (string.Equals(platform, "Orbis", StringComparison.OrdinalIgnoreCase) || string.Equals(platform, "PS3", StringComparison.OrdinalIgnoreCase) || string.Equals(platform, "Prospero", StringComparison.OrdinalIgnoreCase)) { ValidatePackageOrderOrbis(); } } protected void ValidatePackageOrderDurango() { // Rules: // - Must contain all types of file listed in k_SectionNames // - Sections must be in the order specified in k_SectionNames based on their range limits // - Ranges of sections must not overlap, unless AllowSharedIds is true // - Each file entry must lie in a range foreach (var sectionName in _sectionNames) { if (!_packageOrderSections.ContainsKey(sectionName)) throw new PackageConfigException($"Package order doesn't contain section {sectionName}", true); var sec = _packageOrderSections[sectionName]; if (sec.Begin < PackageOrderSection.MinValue) throw new PackageConfigException($"Package order section {sectionName} has an invalid range - Begin less than Min value ({PackageOrderSection.MinValue})", true); if (sec.End > PackageOrderSection.MaxValue) throw new PackageConfigException($"Package order section {sectionName} has an invalid range - End greater than Max value ({PackageOrderSection.MaxValue})", true); if (sec.Begin > sec.End) throw new PackageConfigException($"Package order section {sectionName} has an invalid range - Begin greater than End", true); } for (int i = 0; i < _sectionNames.Count() - 1; i++) { var currentSectionName = _sectionNames[i]; var nextSectionName = _sectionNames[i + 1]; var currentSection = _packageOrderSections[currentSectionName]; var nextSection = _packageOrderSections[nextSectionName]; if ((!currentSection.AllowSharedIds || !nextSection.AllowSharedIds) && currentSection.End >= nextSection.Begin) throw new PackageConfigException($"Package order sections {currentSectionName} and {nextSectionName} have overlapping ranges", true); } foreach (var fileEntry in _fileOrderIndices) { if (string.Equals(fileEntry.Key, _nonBundledEntryName, StringComparison.OrdinalIgnoreCase)) continue; bool contained = false; foreach (var sectionName in _sectionNames) { PackageOrderSection sec = _packageOrderSections[sectionName]; if (fileEntry.Value >= sec.Begin && fileEntry.Value <= sec.End) { contained = true; } } if (contained == false) throw new PackageConfigException($"Package order file entry {fileEntry.Key} - Id outside of all section ranges", true); } if (!_fileOrderIndices.ContainsKey(_nonBundledEntryName)) throw new PackageConfigException($"Package order missing required entry \"{_nonBundledEntryName}\"", true); } protected void ValidatePackageOrderOrbis() { // Rules: // - Must contain all types of file listed in k_SectionNames // - Sections must be in the order specified in k_SectionNames based on their range limits // - Ranges of sections must not overlap, unless AllowSharedIds is true // - Each file entry must lie in a range foreach (var sectionName in _sectionNames) { if (!_packageOrderSections.ContainsKey(sectionName)) throw new PackageConfigException($"Package order doesn't contain section {sectionName}", true); var sec = _packageOrderSections[sectionName]; if (sec.Begin < PackageOrderSection.MinValue) throw new PackageConfigException($"Package order section {sectionName} has an invalid range - Begin less than Min value ({PackageOrderSection.MinValue})", true); if (sec.End > PackageOrderSection.MaxValue) throw new PackageConfigException($"Package order section {sectionName} has an invalid range - End greater than Max value ({PackageOrderSection.MaxValue})", true); if (sec.Begin > sec.End) throw new PackageConfigException($"Package order section {sectionName} has an invalid range - Begin greater than End", true); } for (var i = 0; i < _sectionNames.Count() - 1; i++) { var currentSectionName = _sectionNames[i]; var nextSectionName = _sectionNames[i + 1]; var currentSection = _packageOrderSections[currentSectionName]; var nextSection = _packageOrderSections[nextSectionName]; if ((!currentSection.AllowSharedIds || !nextSection.AllowSharedIds) && currentSection.End >= nextSection.Begin) throw new PackageConfigException($"Package order sections {currentSectionName} and {nextSectionName} have overlapping ranges", true); } foreach (var fileEntry in _fileOrderIndices) { if (string.Equals(fileEntry.Key, _nonBundledEntryName, StringComparison.OrdinalIgnoreCase)) continue; bool contained = false; foreach (var sectionName in _sectionNames) { PackageOrderSection sec = _packageOrderSections[sectionName]; if (fileEntry.Value >= sec.Begin && fileEntry.Value <= sec.End) { contained = true; } } if (contained == false) throw new PackageConfigException($"Package order file entry {fileEntry.Key} - Id outside of all section ranges", true); } if (!_fileOrderIndices.ContainsKey(_nonBundledEntryName)) throw new PackageConfigException($"Package order missing required entry \"{_nonBundledEntryName}\"", true); } private void AddJdmMapsToCommonChunk(string fileName) { if (_fileOrderIndices.ContainsKey(fileName)) return; _fileOrderIndices[fileName] = _jdmMapsCommonChunkId; } private void AddToRequiredFiles(string fileName) { RequiredFiles.Add(fileName); } private int AddPackageOrderEntry(string filename, SectionType type) { var res = -1; var sec = _packageOrderSections[_sectionNames[(int)type]]; if (_fileOrderIndices.ContainsKey(filename)) //this should only happen when rebundling with the package order from a previous bundling. { res = _fileOrderIndices[filename]; } else { int nextKey = sec.Begin; int endKey = sec.End; if (!sec.AllowSharedIds) //reuse Begin if sharing is allowed { while (nextKey <= endKey && _fileOrderIndices.ContainsValue(nextKey)) { nextKey++; } } if (nextKey > endKey) { throw new PackageConfigException($"Couldn't add package order entry for {filename}: No free entries of type {_sectionNames[(int)type]}. Aborting.", true); } _fileOrderIndices[filename] = nextKey; res = nextKey; } return res; } private bool IsOrbisJdmMap(string filename) { var pattern = new Regex(@"^jdm\w+_ORBIS\.ipk$", RegexOptions.IgnoreCase); return pattern.IsMatch(filename); } private bool IsJdLaunchFile(string filename) { var file = filename.ToLower(); return LaunchFiles.Contains(file); } protected int GetLastRequiredId() { var section = _packageOrderSections[_sectionNames[(int)SectionType.DET_REQUIRED]]; var req = _fileOrderIndices.Where(a => (a.Value >= section.Begin && a.Value <= section.End)); var maxReq = -1; foreach (var kvp in req) { maxReq = (kvp.Value > maxReq) ? kvp.Value : maxReq; } return maxReq; } protected void SaveUpdatedPackageOrder(string filenameIn, string filenameOut, string platform) { XElement packageOrderFile = null; // open the package order and read in the whole lot. using (var file = File.OpenText(filenameIn)) { packageOrderFile = XElement.Load(file); } { // replace the current platform with the updated package order IEnumerable platformEntries = from elem in packageOrderFile.Elements("Platform") where (elem.Attribute("Name").Value.ToLower() == platform.ToLower()) select elem; foreach (var platformEntry in platformEntries) { platformEntry.Remove(); } XElement platformElement = new XElement("Platform", new XAttribute("Name", platform)); packageOrderFile.Add(platformElement); platformElement.Add( new XElement("IdRange", new XAttribute("Min", PackageOrderSection.MinValue), new XAttribute("Max", PackageOrderSection.MaxValue) ) ); foreach (var section in _packageOrderSections) { platformElement.Add( new XElement("Section", new XAttribute("Name", section.Key), new XAttribute("Begin", section.Value.Begin), new XAttribute("End", section.Value.End) ) ); } platformElement.Add(new XComment("The file entries are generated by the tool when unlisted files are encountered, but can be modified by hand for extra control")); foreach (var fileEntry in _fileOrderIndices) { platformElement.Add( new XElement("File", new XAttribute("Name", fileEntry.Key), new XAttribute("Id", fileEntry.Value) ) ); } } using (var file = new StreamWriter(filenameOut, false)) { packageOrderFile.Save(file); } } #endregion // For debugging purposes - a text version of the IdTable.idt binary file // #region IdTable // // protected void WriteIdTable(string _filename) // { // List> sortedIds = m_fileOrderIndices.ToList(); // sortedIds.Sort((firstPair, nextPair) => { return firstPair.Value.CompareTo(nextPair.Value); }); // // try // { // using (var file = new System.IO.StreamWriter(_filename, false)) // { // foreach (var entry in sortedIds) // { // file.WriteLine(entry.Key + "\t\t\t" + Convert.ToString(entry.Value)); // } // } // } // catch (System.Exception ex) // { // throw new System.ApplicationException("Error writing id table: " + ex.Message, ex); // } // } // // #endregion #region Template protected abstract class PackageConfigTemplate { protected XElement _platformTemplate; protected List> _entries; protected Dictionary _defaultTagValues; protected Dictionary _tagValues; protected string _outputFilename; public string Platform { get; set; } public string OutputFilename => _outputFilename; public static PackageConfigTemplate Open(string filename, string platform, string sku) { try { using (var file = File.OpenText(filename)) { var xmlTemplate = XElement.Load(file); IEnumerable platformEntries = from elem in xmlTemplate.Elements("Platform") where (elem.Attribute("Name").Value.ToLower() == platform.ToLower()) select elem; if (!platformEntries.Any()) { throw new PackageConfigException("Current platform not specified in template - skipping", false); } if (string.Equals(platform, "Durango", StringComparison.OrdinalIgnoreCase)) { return new PackageConfigTemplateDurango(platformEntries.First()); } if (string.Equals(platform, "Scarlett", StringComparison.OrdinalIgnoreCase)) { return new PackageConfigTemplateScarlett(platformEntries.First()); } if (string.Equals(platform, "Pc", StringComparison.OrdinalIgnoreCase)) { return new PackageConfigTemplatePC(platformEntries.First()); } //Currently PackageConfigTemplate is same for Orbis and PS3. If any change comes, PS3 will require different template if (string.Equals(platform, "Orbis", StringComparison.OrdinalIgnoreCase) || string.Equals(platform, "PS3", StringComparison.OrdinalIgnoreCase)) { XElement el = platformEntries.First(); foreach (XElement element in el.Elements()) { if (element.Name == sku) { return new PackageConfigTemplateOrbis(element); } } throw new PackageConfigException($"SKU '{sku}' not found", true); } if (string.Equals(platform, "Prospero", StringComparison.OrdinalIgnoreCase)) { return new PackageConfigTemplateProspero(platformEntries.First()); } } } catch (DirectoryNotFoundException /*ex*/) { throw new PackageConfigException($"Template file '{filename}' not found", true); } catch (FileNotFoundException /*ex*/) { throw new PackageConfigException($"Template file '{filename}' not found", true); } catch (FormatException /*ex*/) { } catch (OverflowException /*ex*/) { } return null; } protected PackageConfigTemplate(XElement platformTemplate) { this._platformTemplate = platformTemplate; _entries = null; _tagValues = null; XElement outFnElem = platformTemplate.Element("OutputFilename"); if (outFnElem == null) { throw new PackageConfigException("No output filename specified", true); } _outputFilename = outFnElem.Value; LoadDefaultTags(); Validate(); } protected void LoadDefaultTags() { _defaultTagValues = new Dictionary(StringComparer.InvariantCultureIgnoreCase); // load default tags from the template var defaultsElement = _platformTemplate.Element("Defaults"); if (defaultsElement != null) { var defaultTagElements = from elem in defaultsElement.Elements("Default") select elem; foreach (var tag in defaultTagElements) { var nameAttr = tag.Attribute("Name"); var valueAttr = tag.Attribute("Value"); if (nameAttr == null) { throw new PackageConfigException("Template contains element with no 'Name' attribute", true); } if (valueAttr == null) { throw new PackageConfigException("Template contains element with no 'Value' attribute", true); } _defaultTagValues.Add(nameAttr.Value, valueAttr.Value); } } } protected virtual void Validate() { // Ensure all tags contained in Attrib elements are in the list returned from SupportedTags() var supportedTags = SupportedTags(); var attribs = from elem in _platformTemplate.Descendants("Attrib") select elem; foreach (var attrib in attribs) { var attribName = attrib.Attribute("Name"); if (attribName == null) throw new PackageConfigException("Template contains element with no 'Name' attribute", true); var attribValue = attrib.Attribute("Value"); if (attribValue == null) throw new PackageConfigException($"Template contains element '{attribName.Value}' with no 'Value' attribute", true); var parsedTags = ParseTags(attribValue.Value); foreach (var tag in parsedTags) { if (!supportedTags.Contains(tag)) throw new PackageConfigException( $"Template contains element '{attrib.Attribute("Name").Value}' using unsupported tag '{tag}'", true); } } var elemNodes = _platformTemplate.Descendants("Elem"); foreach (var elemNode in elemNodes) { if (elemNode.Attribute("Name") == null) throw new PackageConfigException("Elem node doesn't contain 'Name' attribute", true); } var loopChunkIdsNodes = _platformTemplate.Descendants("LoopChunkIds"); foreach (var loopNode in loopChunkIdsNodes) { if (loopNode.Ancestors("LoopChunkIds").Any()) throw new PackageConfigException("LoopChunkIds has an ancestor element of type LoopChunkIds", true); if (loopNode.Ancestors("LoopFiles").Any()) throw new PackageConfigException("LoopChunkIds cannot have an element of type LoopFiles as an ancestor", true); } var loopFilesNodes = _platformTemplate.Descendants("LoopFiles"); foreach (var loopNode in loopFilesNodes) { if (loopNode.Ancestors("LoopFiles").Any()) throw new PackageConfigException("LoopFiles has an ancestor element of type LoopFiles", true); var filterByChunkIdAttr = loopNode.Attribute("FilterByChunkId"); if (filterByChunkIdAttr != null) { if (string.Equals(filterByChunkIdAttr.Value, "true", StringComparison.OrdinalIgnoreCase)) { if (!loopNode.Ancestors("LoopChunkIds").Any()) { throw new PackageConfigException("LoopFiles is set to FilterByChunkId but has no ancestor element of type LoopChunkIds", true); } } } } var predef = _platformTemplate.Element("Predefined"); if (predef != null) { var predefEntries = from elem in predef.Elements("Entry") select elem; foreach (var entry in predefEntries) { foreach (var tagValue in entry.Attributes()) { if (!supportedTags.Contains(tagValue.Name.LocalName)) { throw new PackageConfigException($"Predefined Entry contains unsupported tag '{tagValue.Name.LocalName}'", true); } } } } } public void Process(StreamWriter outputFile, List> entries) { if (entries == null) throw new PackageConfigException("No entries passed to Xml generation", true); _entries = entries; _tagValues = new Dictionary(); AddGlobalTags(); XElement rootElem = ProcessRoot(_platformTemplate.Element("Elem")); _tagValues = null; rootElem.Save(outputFile); } protected XElement ProcessRoot(XElement elemNode) { string rootName = elemNode.Attribute("Name").Value; XElement rootElem = new XElement(rootName); ProcessChildren(elemNode, rootElem, true, true); return rootElem; } protected void ProcessChildren(XElement templateParent, XElement outputParent, bool supportElem, bool supportAttrib) { IEnumerable allElements = templateParent.Elements(); foreach (var elem in allElements) { if (elem.Name.LocalName == "Elem") { if (!supportElem) { throw new PackageConfigException($"'Elem' element is not supported within {templateParent.Name.LocalName} element", true); } ProcessElem(elem, outputParent); } else if (elem.Name.LocalName == "Attrib") { if (!supportAttrib) { throw new PackageConfigException($"'Attrib' element is not supported within {templateParent.Name.LocalName} element", true); } ProcessAttrib(elem, outputParent); } else if (elem.Name.LocalName == "ElemText") { ProcessElemText(elem, outputParent); } else if (elem.Name.LocalName == "LoopChunkIds") { ProcessLoopChunkIds(elem, outputParent); } else if (elem.Name.LocalName == "LoopFiles") { ProcessLoopFiles(elem, outputParent); } else { throw new PackageConfigException($"Element with name '{elem.Name.LocalName}' is not supported", true); } } } protected void ProcessElem(XElement elemNode, XElement outputParent) { // simply write the element string elemName = elemNode.Attribute("Name").Value; XElement elemToAdd = new XElement(elemName); ProcessChildren(elemNode, elemToAdd, true, true); outputParent.Add(elemToAdd); } protected virtual void ProcessAttrib(XElement attribNode, XElement outputParent) { // determine attribute value by parsing the tags from value, then replacing // each tag with its value. string attrValue = attribNode.Attribute("Value").Value; attrValue = ReplaceTags(attrValue); outputParent.Add(new XAttribute(attribNode.Attribute("Name").Value, attrValue)); // called simply for error checking as no Attrib element should have children, hence the [false, false] parameters ProcessChildren(attribNode, null, false, false); } protected virtual void ProcessElemText(XElement _elemTextNode, XElement _outputParent) { string elemTextValue = _elemTextNode.Attribute("Value").Value; elemTextValue = ReplaceTags(elemTextValue); _outputParent.Value = elemTextValue; // called simply for error checking as no element text should have children, hence the [false, false] parameters ProcessChildren(_elemTextNode, null, false, false); } protected void ProcessLoopChunkIds(XElement _elemNode, XElement _outputParent) { SortedSet uniqueChunkIds = new SortedSet(); //automatically ignores duplicates _entries.ForEach(entry => uniqueChunkIds.Add(entry.Key)); // add predefined chunkIds to the list XElement predefined = _platformTemplate.Element("Predefined"); if (predefined != null) { foreach (var predefEntry in predefined.Elements("Entry")) { XAttribute chunkIdAttr = predefEntry.Attribute("ChunkId"); if (chunkIdAttr == null) { throw new PackageConfigException("Predefined entry contains no chunk id", true); } uniqueChunkIds.Add(Convert.ToInt32(chunkIdAttr.Value)); } } foreach (int id in uniqueChunkIds) { Dictionary prevTags = _tagValues; //create a new dictionary of tags, duplicating existing tags if they exist _tagValues = (prevTags != null) ? new Dictionary(prevTags, StringComparer.InvariantCultureIgnoreCase) : new Dictionary(StringComparer.InvariantCultureIgnoreCase); _tagValues["ChunkId"] = Convert.ToString(id); ProcessChildren(_elemNode, _outputParent, true, false); _tagValues = prevTags; } } protected void ProcessLoopFiles(XElement _elemNode, XElement _outputParent) { int filterByChunkId = -1; XAttribute filterByChunkIdAttr = _elemNode.Attribute("FilterByChunkId"); if (filterByChunkIdAttr != null && String.Equals(filterByChunkIdAttr.Value, "true", StringComparison.OrdinalIgnoreCase)) { if (_tagValues == null) { throw new PackageConfigException("FilterByChunkId used when outside LoopChunkIds block", true); } filterByChunkId = Convert.ToInt32(_tagValues["ChunkId"]); } bool filterByContentLabel = false; XAttribute filterByContentLabelAttr = _elemNode.Attribute("FilterByContentLabel"); if (filterByContentLabelAttr != null && String.Equals(filterByContentLabelAttr.Value, "true", StringComparison.OrdinalIgnoreCase)) { filterByContentLabel = true; } List>> filesToLoop = new List>>(); // add predefined entries XElement predefined = _platformTemplate.Element("Predefined"); if (predefined != null) { foreach (var predefEntry in predefined.Elements("Entry")) { XAttribute chunkIdAttr = predefEntry.Attribute("ChunkId"); if (chunkIdAttr == null) { throw new PackageConfigException("Predefined entry contains no chunk id", true); } int chunkId = Convert.ToInt32(chunkIdAttr.Value); if (filterByChunkId != -1 && filterByChunkId != chunkId) { continue; } XAttribute configLabelAttr = predefEntry.Attribute("ConfigLabel"); if ((filterByContentLabel && configLabelAttr == null) || (!filterByContentLabel && configLabelAttr != null)) { continue; } Dictionary predefEntryTags = new Dictionary(); filesToLoop.Add(new KeyValuePair>(chunkId, predefEntryTags)); foreach (XAttribute attr in predefEntry.Attributes()) { predefEntryTags.Add(attr.Name.LocalName, attr.Value); } } } // add entries from tool foreach (var entry in _entries) { int chunkId = entry.Key; if (filterByChunkId != -1 && filterByChunkId != chunkId) { continue; } if (filterByContentLabel) { continue; } //create a new dictionary of tags, duplicating existing tags if they exist Dictionary entryTags = (_tagValues != null) ? new Dictionary(_tagValues) : new Dictionary(); if (string.Equals(Platform, "PS3", StringComparison.OrdinalIgnoreCase)) { //On PS3, the bundle names are required to be in lowercase. entryTags.Add("EntryFileName", entry.Value.ToLower()); } else { entryTags.Add("EntryFileName", entry.Value); } filesToLoop.Add(new KeyValuePair>(chunkId, entryTags)); } //// sort by chunk id, keeping elements in original order inside each chunk - i.e. use stable OrderBy(), not unstable Sort() filesToLoop.OrderBy((pair) => { return pair.Key; }); foreach (var loopFile in filesToLoop) { Dictionary prevTags = _tagValues; _tagValues = loopFile.Value; _tagValues["ChunkId"] = Convert.ToString(loopFile.Key); ProcessChildren(_elemNode, _outputParent, true, false); _tagValues = prevTags; } } protected virtual List SupportedTags() { var retList = new List(); retList.AddRange(_defaultTagValues.Keys); retList.Add("ChunkId"); retList.Add("EntryFileName"); retList.Add("ChunkCount"); retList.Add("FSCompress"); return retList; } protected virtual void AddGlobalTags() { SortedSet uniqueIds = new SortedSet(); foreach (var entry in _entries) { uniqueIds.Add(entry.Key); } _tagValues.Add("ChunkCount", Convert.ToString(uniqueIds.Count)); } protected string ReplaceTags(string taggedString) { string retStr = taggedString; List parsedTags = ParseTags(retStr); foreach (string tag in parsedTags) { if (_tagValues != null && _tagValues.ContainsKey(tag)) { retStr = retStr.Replace($"%{tag}%", _tagValues[tag]); } else if (_defaultTagValues.ContainsKey(tag)) { retStr = retStr.Replace($"%{tag}%", _defaultTagValues[tag]); } } return retStr; } protected List ParseTags(string _val) { List retList = new List(); string remaining = _val; while (remaining.IndexOf('%') != remaining.LastIndexOf('%')) //breaks when either 0 or 1 % in string { int first = remaining.IndexOf('%'); int second = remaining.IndexOf('%', first + 1); first++; if ((second - 1) < first) { // seem to have found consecutive %s, skip the first remaining = remaining.Substring(first); continue; } string tag = remaining.Substring(first, second - first); if (tag.Split().First() != tag) { //contains whitespace so continue from second remaining = remaining.Substring(second); continue; } retList.Add(tag); int next = second + 1; if (next >= remaining.Length) break; remaining = remaining.Substring(next); } return retList; } } protected class PackageConfigTemplateDurango : PackageConfigTemplate { private int m_lastRequiredChunk; public int LastRequiredChunk { set => m_lastRequiredChunk = value; } public PackageConfigTemplateDurango(XElement _platformTemplate) : base(_platformTemplate) { m_lastRequiredChunk = -1; } protected override List SupportedTags() { List retList = base.SupportedTags(); // insert any Durango specific tags here... return retList; } protected override void ProcessAttrib(XElement attribNode, XElement outputParent) { var nameAttr = attribNode.Attribute("Name"); if (string.Equals(nameAttr.Value, "Marker")) { var condAttr = attribNode.Attribute("Condition"); if (string.Equals(condAttr.Value, "LastRequired", StringComparison.OrdinalIgnoreCase)) { // add the marker tag to the tag dictionary if (_tagValues == null) { throw new PackageConfigException("Marker attribute used outside of LoopChunkIds", true); } var currentChunkId = Convert.ToInt32(_tagValues["ChunkId"]); if (currentChunkId != m_lastRequiredChunk || m_lastRequiredChunk == -1) { return; //don't process this Attrib } } else if (string.Equals(condAttr.Value, "Last", StringComparison.OrdinalIgnoreCase)) { // add the marker tag to the tag dictionary if (_tagValues == null) { throw new PackageConfigException("Marker attribute used outside of LoopChunkIds", true); } var currentChunkId = Convert.ToInt32(_tagValues["ChunkId"]); if (currentChunkId != 1073741823) // Alignment chunk is always last, and this ID { return; //don't process this Attrib } } } base.ProcessAttrib(attribNode, outputParent); } } protected class PackageConfigTemplateScarlett : PackageConfigTemplate { private int m_lastRequiredChunk; public int LastRequiredChunk { set => m_lastRequiredChunk = value; } public PackageConfigTemplateScarlett(XElement platformTemplate) : base(platformTemplate) { m_lastRequiredChunk = -1; } protected override List SupportedTags() { List retList = base.SupportedTags(); // insert any Scarlett specific tags here... return retList; } protected override void ProcessAttrib(XElement attribNode, XElement outputParent) { var nameAttr = attribNode.Attribute("Name"); if (string.Equals(nameAttr.Value, "Marker")) { var condAttr = attribNode.Attribute("Condition"); if (string.Equals(condAttr.Value, "LastRequired", StringComparison.OrdinalIgnoreCase)) { // add the marker tag to the tag dictionary if (_tagValues == null) { throw new PackageConfigException("Marker attribute used outside of LoopChunkIds", true); } var currentChunkId = Convert.ToInt32(_tagValues["ChunkId"]); if (currentChunkId != m_lastRequiredChunk || m_lastRequiredChunk == -1) { return; //don't process this Attrib } } else if (string.Equals(condAttr.Value, "Last", StringComparison.OrdinalIgnoreCase)) { // add the marker tag to the tag dictionary if (_tagValues == null) { throw new PackageConfigException("Marker attribute used outside of LoopChunkIds", true); } var currentChunkId = Convert.ToInt32(_tagValues["ChunkId"]); if (currentChunkId != 1073741823) // Alignment chunk is always last, and this ID { return; //don't process this Attrib } } } base.ProcessAttrib(attribNode, outputParent); } } protected class PackageConfigTemplatePC : PackageConfigTemplate { private int _lastRequiredChunk; public int LastRequiredChunk { set { _lastRequiredChunk = value; } } public PackageConfigTemplatePC(XElement platformTemplate) : base(platformTemplate) { _lastRequiredChunk = -1; } protected override void ProcessAttrib(XElement attribNode, XElement outputParent) { var nameAttr = attribNode.Attribute("Name"); if (string.Equals(nameAttr.Value, "required")) { var condAttr = attribNode.Attribute("Condition"); if (condAttr != null && string.Equals(condAttr.Value, "Required", StringComparison.OrdinalIgnoreCase)) { if (_tagValues == null) { throw new PackageConfigException("Marker attribute used outside of LoopChunkIds", true); } var currentChunkId = Convert.ToInt32(_tagValues["ChunkId"]); if (currentChunkId <= _lastRequiredChunk || _lastRequiredChunk == -1) { attribNode.SetAttributeValue("Value", "true"); } else { attribNode.SetAttributeValue("Value", "false"); } } } base.ProcessAttrib(attribNode, outputParent); } } protected class PackageConfigTemplateOrbis : PackageConfigTemplate { public PackageConfigTemplateOrbis(XElement platformTemplate) : base(platformTemplate) { } protected override List SupportedTags() { List retList = base.SupportedTags(); // insert any Orbis specific tags here... retList.Add("ScenarioAllChunks"); return retList; } protected override void AddGlobalTags() { base.AddGlobalTags(); // Generate the scenario chunk list. Format example: 0-3 5 8-10 var uniqueIds = new SortedSet(); foreach (var entry in _entries) { uniqueIds.Add(entry.Key); } var scenarioAllChunks = ""; var startId = uniqueIds.First(); var lastId = startId; foreach (int entryId in uniqueIds) { if (entryId > (lastId + 1)) // test for non-consecutive Ids { if (lastId == startId) scenarioAllChunks += $"{lastId} "; else scenarioAllChunks += $"{startId}-{lastId} "; startId = entryId; lastId = entryId; } else { lastId = entryId; } } // add the last ids if (lastId == startId) scenarioAllChunks += $"{lastId} "; else scenarioAllChunks += $"{startId}-{lastId} "; scenarioAllChunks = scenarioAllChunks.TrimEnd(' '); _tagValues.Add("ScenarioAllChunks", scenarioAllChunks); } } protected class PackageConfigTemplateProspero : PackageConfigTemplate { public PackageConfigTemplateProspero(XElement platformTemplate) : base(platformTemplate) { } protected override List SupportedTags() { List retList = base.SupportedTags(); // insert any Prospero specific tags here... retList.Add("ScenarioAllChunks"); retList.Add("ConfigLabel"); return retList; } protected override void AddGlobalTags() { base.AddGlobalTags(); // Generate the scenario chunk list. Format example: 0-3 5 8-10 var uniqueIds = new SortedSet(); foreach (var entry in _entries) { uniqueIds.Add(entry.Key); } var scenarioAllChunks = ""; var startId = uniqueIds.First(); var lastId = startId; foreach (int entryId in uniqueIds) { if (entryId > (lastId + 1)) // test for non-consecutive Ids { if (lastId == startId) scenarioAllChunks += $"{lastId} "; else scenarioAllChunks += $"{startId}-{lastId} "; startId = entryId; lastId = entryId; } else { lastId = entryId; } } // add the last ids if (lastId == startId) scenarioAllChunks += $"{lastId} "; else scenarioAllChunks += $"{startId}-{lastId} "; scenarioAllChunks = scenarioAllChunks.TrimEnd(' '); _tagValues.Add("ScenarioAllChunks", scenarioAllChunks); } } #endregion #region Nested private class PackageConfigException : ApplicationException { protected bool fatal; public bool Fatal { get; private set; } public PackageConfigException(bool _fatal) : base() { fatal = _fatal; } public PackageConfigException(string _message, bool _fatal) : base(_message) { fatal = _fatal; } public PackageConfigException(string _message, Exception _innerEx, bool _fatal) : base(_message, _innerEx) { fatal = _fatal; } } #endregion } [TestFixture] public class GeneratePackageConfigTaskTest { private readonly GeneratePackageConfigTask _generatePackageConfig = new GeneratePackageConfigTask(); private readonly GeneratePackageConfigTask _generatePackageConfigPC = new GeneratePackageConfigTask(); private readonly TestBuildConfiguration _testProfile; private readonly TestBuildConfiguration _testProfilePC; public GeneratePackageConfigTaskTest() { _generatePackageConfig.OptionalFiles.Add(CreateBundleFiles()); _generatePackageConfig.PackageOrderFile.Value = DataPath.GetAbsolutePath(@"EngineData\GameToolPackageTask\packageorder.xml"); _generatePackageConfig.TemplateFile.Value = DataPath.GetAbsolutePath(@"EngineData\GameToolPackageTask\chunkmappingtemplate.xml"); _testProfile = new TestBuildConfiguration(new[] { @"dataversion=0;platform=ORBIS;" }); //Test case for Uplay PC manifest.xml _generatePackageConfigPC.RequiredFiles.Add(CreateRequiredBundleFilesPC()); _generatePackageConfigPC.OptionalFiles.Add(CreateOptionalBundleFilesPC()); _generatePackageConfigPC.PackageOrderFile.Value = DataPath.GetAbsolutePath(@"EngineData\GameToolPackageTask\packageorder.xml"); _generatePackageConfigPC.TemplateFile.Value = DataPath.GetAbsolutePath(@"EngineData\GameToolPackageTask\chunkmappingtemplate.xml"); _testProfilePC = new TestBuildConfiguration(new[] { @"dataversion=0;platform=Pc;" }); } [Test] public void TestPcManifestGeneration() { if (!Directory.Exists(_generatePackageConfigPC.TaskOutputFolder)) Directory.CreateDirectory(_generatePackageConfigPC.TaskOutputFolder); _generatePackageConfigPC.StartTaskTest(_testProfilePC); Assert.IsTrue(Directory.Exists(_generatePackageConfigPC.TaskOutputFolder + "mainfest.xml")); } [Test] public void TestPuttingAllJdmMachineMapsInCommonChunk() { if (!Directory.Exists(_generatePackageConfig.TaskOutputFolder)) Directory.CreateDirectory(_generatePackageConfig.TaskOutputFolder); File.Create(Path.Combine(_generatePackageConfig.TaskOutputFolder, @"ChunkMapping_SCEA.gp4")); _generatePackageConfig.StartTaskTest(_testProfile); var outputFileData = File.ReadAllLines(Path.Combine(_generatePackageConfig.TaskOutputFolder, "UpdatedPackageOrder.xml")); var regularMapPattern = new Regex("[^jdm]\\w+_ORBIS\\.ipk\\\")\\s+Id=\\\"(?\\d+)\\\"", RegexOptions.IgnoreCase); int maxRegularMapChunkId = 0; outputFileData.ForEach(x => { var match = regularMapPattern.Match(x); if (match.Success) maxRegularMapChunkId = int.Parse(match.Groups["Id"].Value); }); var jdmMapPattern = new Regex("jdm\\w+_ORBIS\\.ipk)\\s+Id=(?\\\"\\d+\\\")", RegexOptions.IgnoreCase); outputFileData.ForEach(x => { var match = jdmMapPattern.Match(x); if (match.Success) Assert.AreEqual(int.Parse(match.Groups["ID"].Value), maxRegularMapChunkId + 1); }); } private string[] CreateRequiredBundleFilesPC() { return new[] { "bundle_pc.ipk", "bundlelogic_pc.ipk", "secure_fat.gf" }; } private string[] CreateOptionalBundleFilesPC() { return new[] { "allaboutus_pc.ipk", "bailar_pc.ipk", "bang_pc.ipk", "bonbon_pc.ipk", "cakebytheocean_pc.ipk", "cakebytheoceanalt_pc.ipk", "cantfeelmyface_pc.ipk" }; } private string[] CreateBundleFiles() { return new[] { "_testmapshort_ORBIS.ipk", "_testmapshort2_ORBIS.ipk", "8bitclassical_ORBIS.ipk", "bang_ORBIS.ipk", "cantfeelmyface_ORBIS.ipk", "cantfeelmyfacemu_ORBIS.ipk", "cheapthrills_ORBIS.ipk", "circusmu_ORBIS.ipk", "colasong_ORBIS.ipk", "daddy_ORBIS.ipk", "daddyalt_ORBIS.ipk", "daddymu_ORBIS.ipk", "dontstopme_ORBIS.ipk", "dontstopmealt_ORBIS.ipk", "dontstopmemu_ORBIS.ipk", "dragosteadintei_ORBIS.ipk", "eltiki_ORBIS.ipk", "ghostinthekeys_ORBIS.ipk", "ghostinthekeysmu_ORBIS.ipk", "groove_ORBIS.ipk", "hips_ORBIS.ipk", "hipsalt_ORBIS.ipk", "hipsmu_ORBIS.ipk", "itsybitsy_ORBIS.ipk", "jdmafricandance_ORBIS.ipk", "jdmapplauseconcert_ORBIS.ipk", "jdmballerina_ORBIS.ipk", "jdmballerinaalt01_ORBIS.ipk", "jdmbollywood_ORBIS.ipk", "jdmbollywoodalt01_ORBIS.ipk", "jdmbrucelee_ORBIS.ipk", "jdmbsballerina_ORBIS.ipk", "jdmbscharleston_ORBIS.ipk", "jdmbsegyptian_ORBIS.ipk", "jdmbslimbo_ORBIS.ipk", "jdmbsrunningman_ORBIS.ipk", "jdmbstwerk_ORBIS.ipk", "jdmcharleston_ORBIS.ipk", "jdmcharlestonalt01_ORBIS.ipk", "jdmcheerleader_ORBIS.ipk", "jdmcircuslion_ORBIS.ipk", "jdmegyptian_ORBIS.ipk", "jdmflamenco_ORBIS.ipk", "jdmflamencoalt01_ORBIS.ipk", "jdmfrenchcancan_ORBIS.ipk", "jdmgi_ORBIS.ipk", "jdmgialt01_ORBIS.ipk", "jdmgymnast_ORBIS.ipk", "jdmgymnastalt01_ORBIS.ipk", "jdmhaka_ORBIS.ipk", "jdmisidora_ORBIS.ipk", "jdmkungfu_ORBIS.ipk", "jdmkungfualt01_ORBIS.ipk", "jdmlimbo_ORBIS.ipk", "jdmmaestro_ORBIS.ipk", "jdmmagicmike_ORBIS.ipk", "jdmmetaldrum_ORBIS.ipk", "jdmmetalguitar_ORBIS.ipk", "jdmmetalheadbang_ORBIS.ipk", "jdmnaenae_ORBIS.ipk", "jdmorientaldance_ORBIS.ipk", "jdmrapper_ORBIS.ipk", "jdmraveparty_ORBIS.ipk", "jdmrobot_ORBIS.ipk", "jdmrunningman_ORBIS.ipk", "jdmsaloon_ORBIS.ipk", "jdmsirtaki_ORBIS.ipk", "jdmstripteaseburlesque_ORBIS.ipk", "jdmtectonic_ORBIS.ipk", "jdmtwerk_ORBIS.ipk", "jdmunderwater_ORBIS.ipk", "jdmwestcoast_ORBIS.ipk", "jdmyodel_ORBIS.ipk", "jdmyoga_ORBIS.ipk", "leanon_ORBIS.ipk", "leanonalt_ORBIS.ipk", "leanonmu_ORBIS.ipk", "leila_ORBIS.ipk", "oishii_ORBIS.ipk", "popipo_ORBIS.ipk", "radical_ORBIS.ipk", "radicalalt_ORBIS.ipk", "radicalmu_ORBIS.ipk", "runthenight_ORBIS.ipk", "samba_ORBIS.ipk", "screamnshout_ORBIS.ipk", "screamnshoutalt_ORBIS.ipk", "screamnshoutmu_ORBIS.ipk", "september_ORBIS.ipk", "septembermu_ORBIS.ipk", "sorry_ORBIS.ipk", "sorryalt_ORBIS.ipk", "sorrymu_ORBIS.ipk", "ticotico_ORBIS.ipk", "ticoticomu_ORBIS.ipk", "titanium_ORBIS.ipk", "titaniumalt_ORBIS.ipk", "whatislove_ORBIS.ipk", "whatislovealt_ORBIS.ipk", "whatislovemu_ORBIS.ipk", "worthit_ORBIS.ipk", "worthitalt_ORBIS.ipk", "worthitmu_ORBIS.ipk" }; } } }