diff --git a/assets/help/img/wta_edit.png b/assets/help/img/wta_edit.png index bc04ddd..ba9016d 100644 Binary files a/assets/help/img/wta_edit.png and b/assets/help/img/wta_edit.png differ diff --git a/assets/help/wta.md b/assets/help/wta.md index 003f1c6..33d55c5 100644 --- a/assets/help/wta.md +++ b/assets/help/wta.md @@ -10,9 +10,9 @@ Textures are stored either in a single .wtb file or in separate .wta and .wtp fi ## Extracting -Open a .wta or .wtb file by dragging & dropping it into the File Explorer or by clicking the folder icon and selecting the file. +Open a .wta or .wtb file or their extracted folder by dragging & dropping it into the File Explorer or by clicking the folder icon and selecting the file. -When opening a .wta or .wtb file, all textures are extracted to disk. When opening a WTA and the corresponding WTP is not found, the file will fail to load. +When opening a .wta or .wtb file for the first time, all textures are extracted to disk. When reopening the file, the textures are loaded from the extracted folder, or individual textures are re-extracted if missing. ## Editing textures @@ -29,6 +29,11 @@ Find the texture you want to edit, based on the id or preview image. Either chan Press the (+) button to add a new texture. To remove a texture, click the delete button. +### Replace or add all textures from a folder + +Press the floating button in the bottom left corner and select a folder with textures inside. F-SERVO will find all dds files with an id. +If a texture id is already in the WTA, its path will be replaced. Otherwise, a new texture will be added. + ## Notes - Changing the id is only relevant when adding new textures diff --git a/lib/fileTypeUtils/wta/wtaExtractor.dart b/lib/fileTypeUtils/wta/wtaExtractor.dart index fb7cba6..66dd020 100644 --- a/lib/fileTypeUtils/wta/wtaExtractor.dart +++ b/lib/fileTypeUtils/wta/wtaExtractor.dart @@ -15,7 +15,12 @@ Future> extractWta(String wtaPath, String? wtpPath, bool isWtb) asy try { for (int i = 0; i < wta.textureOffsets.length; i++) { var texturePath = getWtaTexturePath(wta, i, extractDir); + var texturePathOld = getWtaTexturePathOld(wta, i, extractDir); texturePaths.add(texturePath); + if (await File(texturePathOld).exists()) + await File(texturePathOld).rename(texturePath); + if (await File(texturePath).exists()) + continue; await wtpFile.setPosition(wta.textureOffsets[i]); var textureBytes = await wtpFile.read(wta.textureSizes[i]); await File(texturePath).writeAsBytes(textureBytes); @@ -26,9 +31,16 @@ Future> extractWta(String wtaPath, String? wtpPath, bool isWtb) asy return texturePaths; } +String getWtaTexturePathOld(WtaFile wta, int i, String extractDir) { + String idStr = ""; + if (wta.textureIdx != null) + idStr = "_${wta.textureIdx![i].toRadixString(16)}"; + return join(extractDir, "$i$idStr.dds"); +} + String getWtaTexturePath(WtaFile wta, int i, String extractDir) { String idStr = ""; if (wta.textureIdx != null) - idStr = "_${wta.textureIdx![i].toRadixString(16).padLeft(8, "0")}"; + idStr = "_${wta.textureIdx![i].toString()}"; return join(extractDir, "$i$idStr.dds"); } diff --git a/lib/stateManagement/hierarchy/FileHierarchy.dart b/lib/stateManagement/hierarchy/FileHierarchy.dart index eb2795d..d6f30fd 100644 --- a/lib/stateManagement/hierarchy/FileHierarchy.dart +++ b/lib/stateManagement/hierarchy/FileHierarchy.dart @@ -151,11 +151,11 @@ class OpenHierarchyManager with HasUuid, Undoable, HierarchyEntryBase implements bxmExtensions, () async => openBxmFile(filePath, parent: parent) ), Tuple2( - [".wta"], + [".wta", ".wta_extracted"], () async => openWtaFile(filePath, parent: parent) ), Tuple2( - [".wtb"], + [".wtb", ".wtb_extracted"], () async => openWtbFile(filePath, parent: parent) ), Tuple2( @@ -466,7 +466,7 @@ class OpenHierarchyManager with HasUuid, Undoable, HierarchyEntryBase implements if (existing != null) return existing; - var wtaEntry = WtaHierarchyEntry(StringProp(basename(wtaPath), fileId: null), wtaPath); + var wtaEntry = WtaHierarchyEntry(StringProp(basename(wtaPath).replaceAll("_extracted", ""), fileId: null), wtaPath); if (parent != null) parent.add(wtaEntry); else @@ -476,11 +476,11 @@ class OpenHierarchyManager with HasUuid, Undoable, HierarchyEntryBase implements } Future openWtbFile(String wtaPath, { HierarchyEntry? parent }) async { - var existing = findRecWhere((entry) => entry is WtaHierarchyEntry && entry.path == wtaPath); + var existing = findRecWhere((entry) => entry is WtbHierarchyEntry && entry.path == wtaPath); if (existing != null) return existing; - var wtaEntry = WtbHierarchyEntry(StringProp(basename(wtaPath), fileId: null), wtaPath); + var wtaEntry = WtbHierarchyEntry(StringProp(basename(wtaPath).replaceAll("_extracted", ""), fileId: null), wtaPath); if (parent != null) parent.add(wtaEntry); else diff --git a/lib/stateManagement/openFiles/openFileTypes.dart b/lib/stateManagement/openFiles/openFileTypes.dart index 7a77dcc..525aefa 100644 --- a/lib/stateManagement/openFiles/openFileTypes.dart +++ b/lib/stateManagement/openFiles/openFileTypes.dart @@ -94,7 +94,7 @@ abstract class OpenFileData with HasUuid, Undoable, Disposable, HasUndoHistory { return BnkFilePlaylistData(name, path, secondaryName: secondaryName); else if (path.endsWith(".dat") && basename(path).startsWith("SlotData_")) return SaveSlotData(name, path, secondaryName: secondaryName); - else if (path.endsWith(".wta") || path.endsWith(".wtb")) + else if (path.endsWith(".wta") || path.endsWith(".wtb") || path.endsWith(".wta_extracted") || path.endsWith(".wtb_extracted")) return WtaWtpData(name, path, secondaryName: secondaryName, isWtb: path.endsWith(".wtb")); else if (path.endsWith(".est") || path.endsWith(".sst")) return EstFileData(name, path, secondaryName: secondaryName); diff --git a/lib/stateManagement/openFiles/types/WtaWtpData.dart b/lib/stateManagement/openFiles/types/WtaWtpData.dart index 3b06fa4..e609c31 100644 --- a/lib/stateManagement/openFiles/types/WtaWtpData.dart +++ b/lib/stateManagement/openFiles/types/WtaWtpData.dart @@ -1,6 +1,7 @@ import 'dart:io'; +import 'package:file_picker/file_picker.dart' as fp; import 'package:flutter/material.dart'; import 'package:path/path.dart'; @@ -39,33 +40,43 @@ class WtaWtpData extends OpenFileData { return; loadingState.value = LoadingState.loading; - // find wtp - var datDir = dirname(path); - var dttDir = isWtb ? null : await findDttDirOfDat(datDir); - if (wtpPath == null && !isWtb) { - var wtaName = basenameWithoutExtension(path); - var wtpName = "$wtaName.wtp"; - // wtpPath = join(dttDir, wtpName); - if (dttDir != null) - wtpPath = join(dttDir, wtpName); - else { - wtpPath = join(datDir, wtpName); - if (!await File(wtpPath!).exists()) { - showToast("Can't find corresponding WTP file for $wtaName.wta in ${dttDir ?? datDir}"); - throw Exception("Can't find corresponding WTP file for $wtaName"); + if (await File(path).exists()) { + // find wtp + var datDir = dirname(path); + var dttDir = isWtb ? null : await findDttDirOfDat(datDir); + if (wtpPath == null && !isWtb) { + var wtaName = basenameWithoutExtension(path); + var wtpName = "$wtaName.wtp"; + // wtpPath = join(dttDir, wtpName); + if (dttDir != null) + wtpPath = join(dttDir, wtpName); + else { + wtpPath = join(datDir, wtpName); + if (!await File(wtpPath!).exists()) { + showToast("Can't find corresponding WTP file for $wtaName.wta in ${dttDir ?? datDir}"); + throw Exception("Can't find corresponding WTP file for $wtaName"); + } } } - } - if (!isWtb && wtpPath == null) { - showToast("Can't find corresponding WTP file in ${dttDir ?? datDir}"); - throw Exception("Can't find corresponding WTP file in ${dttDir ?? datDir}"); - } + if (!isWtb && wtpPath == null) { + showToast("Can't find corresponding WTP file in ${dttDir ?? datDir}"); + throw Exception("Can't find corresponding WTP file in ${dttDir ?? datDir}"); + } - String extractDir = join(dttDir ?? datDir, "${basename(path)}_extracted"); - await Directory(extractDir).create(recursive: true); + String extractDir = join(dttDir ?? datDir, "${basename(path)}_extracted"); + await Directory(extractDir).create(recursive: true); - textures?.dispose(); - textures = await WtaWtpTextures.fromWtaWtp(uuid, path, wtpPath, extractDir, isWtb); + textures?.dispose(); + textures = await WtaWtpTextures.fromWtaWtp(uuid, path, wtpPath, extractDir, isWtb); + } + else if (await Directory(path).exists()) { + textures?.dispose(); + textures = await WtaWtpTextures.fromExtractedFolder(uuid, path); + } + else { + showToast("File not found: $path"); + return; + } await super.load(); } @@ -112,7 +123,7 @@ class WtaWtpData extends OpenFileData { class WtaTextureEntry with HasUuid, Undoable implements Disposable { final OpenFileId file; - final HexProp? id; + final NumberProp? id; final StringProp path; final BoolProp? isAlbedo; final HexProp? flag; @@ -144,7 +155,7 @@ class WtaTextureEntry with HasUuid, Undoable implements Disposable { Undoable takeSnapshot() { var snap = WtaTextureEntry( file, - id?.takeSnapshot() as HexProp?, + id?.takeSnapshot() as NumberProp?, path.takeSnapshot() as StringProp, isAlbedo: isAlbedo?.takeSnapshot() as BoolProp?, flag: flag?.takeSnapshot() as HexProp?, @@ -165,8 +176,8 @@ class WtaTextureEntry with HasUuid, Undoable implements Disposable { class WtaWtpTextures with HasUuid, Undoable implements Disposable { final OpenFileId file; - final String wtaPath; - final String? wtpPath; + String? wtaPath; + String? wtpPath; final ValueNotifier?> wtpDatsPath = ValueNotifier(null); final bool isWtb; final int wtaVersion; @@ -203,9 +214,14 @@ class WtaWtpTextures with HasUuid, Undoable implements Disposable { messageLog.add("Extracting texture ${i + 1}/${wta.textureOffsets.length}"); // var texturePath = join(extractDir, "${i}_${wta.textureIdx[i].toRadixString(16).padLeft(8, "0")}.dds"); var texturePath = getWtaTexturePath(wta, i, extractDir); - await wtpFile.setPosition(wta.textureOffsets[i]); - var textureBytes = await wtpFile.read(wta.textureSizes[i]); - await File(texturePath).writeAsBytes(textureBytes); + var texturePathOld = getWtaTexturePathOld(wta, i, extractDir); + if (await File(texturePathOld).exists()) + await File(texturePathOld).rename(texturePath); + if (!await File(texturePath).exists()) { + await wtpFile.setPosition(wta.textureOffsets[i]); + var textureBytes = await wtpFile.read(wta.textureSizes[i]); + await File(texturePath).writeAsBytes(textureBytes); + } BoolProp? isAlbedo; HexProp? flag; if (wta.textureFlags[i] == WtaFile.albedoFlag || wta.textureFlags[i] == WtaFile.noAlbedoFlag) @@ -215,7 +231,7 @@ class WtaWtpTextures with HasUuid, Undoable implements Disposable { textures.add(WtaTextureEntry( file, wta.textureIdx != null - ? HexProp(wta.textureIdx![i], fileId: file) + ? NumberProp(wta.textureIdx![i], true, fileId: file) : null, StringProp(texturePath, fileId: file), isAlbedo: isAlbedo, @@ -234,7 +250,111 @@ class WtaWtpTextures with HasUuid, Undoable implements Disposable { return WtaWtpTextures(file, wtaPath, wtpPath, isWtb, wtaVersion, textures, useFlagsSimpleMode, hashAnySimpleModeFlags); } + static Future<({List textures, bool hasId})> _findTexturesInFolder(String folder, bool isWtb, {bool? hasId}) async { + var fileNamePattern = RegExp(r"^(\d+_)?[0-9a-fA-F]+\.dds$"); + var hexCheckPattern = RegExp(r"[a-fA-F]+\d*\.dds$"); + var textureFiles = await Directory(folder).list() + .where((e) => e is File) + .map((e) => e.path) + .where((e) => fileNamePattern.hasMatch(basename(e))) + .toList(); + var hasIndex = textureFiles.any((e) => basename(e).startsWith("0_") || basename(e) == "0.dds"); + hasId ??= !hasIndex || textureFiles.any((e) => basename(e).contains("_")); + var usesHex = textureFiles.any((e) => hexCheckPattern.hasMatch(basename(e))); + var typePatterns = { + // hasIndex, hasId + (false, true): RegExp(r"^([0-9a-fA-F]+)\.dds$"), + (true, false): RegExp(r"^(\d+)\.dds$"), + (true, true): RegExp(r"^(\d+)_([0-9a-fA-F]+)\.dds$"), + }; + var typePattern = typePatterns[(hasIndex, hasId)]!; + List<(int?, WtaTextureEntry)> textures = []; + for (var file in textureFiles) { + var match = typePattern.firstMatch(basename(file)); + var index = hasIndex ? int.parse(match!.group(1)!) : null; + var id = hasId ? int.parse(match!.group(hasId ? 2 :1)!, radix: usesHex ? 16 : 10) : null; + textures.add((index, WtaTextureEntry( + file, + id != null ? NumberProp(id, true, fileId: file) : null, + StringProp(file, fileId: file), + flag: HexProp(0x20000020, fileId: file), + ))); + } + + textures.sort((a, b) { + if (a.$1 != null && b.$1 != null) + return a.$1!.compareTo(b.$1!); + return a.$2.file.compareTo(b.$2.file); + }); + + return ( + textures: textures.map((e) => e.$2).toList(), + hasId: hasId, + ); + } + + static Future fromExtractedFolder(OpenFileId file, String folder) async { + var filename = basename(folder).replaceAll("_extracted", ""); + var isWtb = filename.endsWith(".wtb"); + if (!isWtb && !filename.endsWith(".wta")) { + showToast("Unexpected folder name: $filename"); + throw Exception("Unexpected folder name: $filename"); + } + var (:textures, :hasId) = await _findTexturesInFolder(folder, isWtb); + var valueList = ValueListNotifier(textures, fileId: file); + return WtaWtpTextures( + file, + null, null, + isWtb, + hasId ? 1 : 0, + valueList, + false, + false, + ); + } + + Future patchFromFolder(String folder) async { + var (textures: folderTextures, hasId: _) = await _findTexturesInFolder(folder, isWtb, hasId: true); + int added = 0; + int updated = 0; + for (var folderTexture in folderTextures) { + var currentTexture = textures.where((e) => e.id!.value == folderTexture.id!.value).firstOrNull; + if (currentTexture != null) { + currentTexture.path.value = folderTexture.path.value; + folderTexture.dispose(); + updated++; + } + else { + textures.add(folderTexture); + added++; + } + } + showToast("Added $added textures, updated $updated textures"); + } + Future save() async { + if (wtaPath == null) { + var result = await fp.FilePicker.platform.saveFile( + allowedExtensions: [isWtb ? "wtb" : "wta"], + type: fp.FileType.custom, + dialogTitle: isWtb ? "Save WTB" : "Save WTA", + ); + if (result == null) + return; + wtaPath = result; + } + if (wtpPath == null && !isWtb) { + var result = await fp.FilePicker.platform.saveFile( + allowedExtensions: ["wtp"], + type: fp.FileType.custom, + dialogTitle: "Save WTP", + fileName: "${basenameWithoutExtension(wtaPath!)}.wtp", + ); + if (result == null) + return; + wtpPath = result; + } + var wta = WtaFile( WtaFileHeader.empty(version: wtaVersion), List.filled(textures.length, -1), @@ -248,7 +368,7 @@ class WtaWtpTextures with HasUuid, Undoable implements Disposable { showToast("Mismatch: WTA has texture indices, but some textures are missing indices!"); throw Exception("Mismatch: WTA has texture indices, but some textures are missing indices!"); } - wta.textureIdx = List.generate(textures.length, (index) => textures[index].id!.value); + wta.textureIdx = List.generate(textures.length, (index) => textures[index].id!.value.toInt()); } wta.updateHeader(isWtb: isWtb); @@ -262,12 +382,12 @@ class WtaWtpTextures with HasUuid, Undoable implements Disposable { } // write wta - await backupFile(wtaPath); - await wta.writeToFile(wtaPath); + await backupFile(wtaPath!); + await wta.writeToFile(wtaPath!); messageLog.add("Saved WTA"); // write wtp - var textureFilePath = isWtb ? wtaPath : wtpPath!; + var textureFilePath = isWtb ? wtaPath! : wtpPath!; await backupFile(textureFilePath); var wtpFile = await File(textureFilePath).open(mode: isWtb ? FileMode.append : FileMode.write); try { diff --git a/lib/widgets/filesView/fileTabView.dart b/lib/widgets/filesView/fileTabView.dart index 29f8a56..30ea260 100644 --- a/lib/widgets/filesView/fileTabView.dart +++ b/lib/widgets/filesView/fileTabView.dart @@ -77,7 +77,10 @@ class _FileTabViewState extends ChangeNotifierState { for (var file in files) { var fileName = path.basename(file); bool isSaveSlotData = fileName.startsWith("SlotData_") && fileName.endsWith(".dat"); - if (fileExplorerExtensions.contains(path.extension(fileName)) && !isSaveSlotData || await Directory(file).exists()) { + var isFileExplorerFile = fileExplorerExtensions.contains(path.extension(fileName)); + var isFolder = await Directory(file).exists(); + var isExtractedWta = path.basename(file).endsWith(".wta_extracted") || path.basename(file).endsWith(".wtb_extracted"); + if (isFileExplorerFile && !isSaveSlotData || isFolder && !isExtractedWta) { var entry = await openHierarchyManager.openFile(file); if (entry?.isOpenable == true) entry!.onOpen(); diff --git a/lib/widgets/filesView/types/wtaWtpEditor.dart b/lib/widgets/filesView/types/wtaWtpEditor.dart index 84bbb09..2cba543 100644 --- a/lib/widgets/filesView/types/wtaWtpEditor.dart +++ b/lib/widgets/filesView/types/wtaWtpEditor.dart @@ -16,6 +16,7 @@ import '../../misc/ChangeNotifierWidget.dart'; import '../../misc/expandOnHover.dart'; import '../../misc/imagePreviewBuilder.dart'; import '../../propEditors/propTextField.dart'; +import '../../theme/customTheme.dart'; import 'genericTable/tableEditor.dart'; class WtaWtpEditor extends StatefulWidget { @@ -102,7 +103,7 @@ class _TexturesTableConfig with CustomTableConfig { void onRowAdd() { textures.add(WtaTextureEntry( texData.file, - HexProp(randomId(), fileId: file.uuid), + NumberProp(randomId(), true, fileId: file.uuid), StringProp("", fileId: file.uuid), isAlbedo: texData.useFlagsSimpleMode ? BoolProp(false, fileId: file.uuid) : null, flag: texData.useFlagsSimpleMode ? null : HexProp(textures.isNotEmpty ? textures.last.flag!.value : 0, fileId: file.uuid), @@ -116,41 +117,13 @@ class _TexturesTableConfig with CustomTableConfig { file.onUndoableEvent(); } - Future patchImportFolder() async { - var folderSel = await FilePicker.platform.getDirectoryPath( + Future patchFromFolder() async { + var paths = await FilePicker.platform.getDirectoryPath( dialogTitle: "Select folder with DDS files", ); - if (folderSel == null) - return; - var allDdsFiles = await Directory(folderSel).list() - .where((e) => e is File && e.path.endsWith(".dds")) - .map((e) => e.path) - .toList(); - List ddsFilesOrdered = List.generate(allDdsFiles.length, (index) => ""); - for (var dds in allDdsFiles) { - var indexRes = RegExp(r"^\d+").firstMatch(basename(dds)); - if (indexRes == null) - continue; - int index = int.parse(indexRes.group(0)!); - if (index >= ddsFilesOrdered.length) { - showToast("Index $index is out of range (${ddsFilesOrdered.length})"); - return; - } - ddsFilesOrdered[index] = dds; - } - if (ddsFilesOrdered.any((e) => e.isEmpty)) { - showToast("Some DDS files are missing"); + if (paths == null) return; - } - textures.addAll(ddsFilesOrdered.map((file) => WtaTextureEntry( - texData.file, - HexProp(randomId(), fileId: this.file.uuid), - StringProp(file, fileId: this.file.uuid), - isAlbedo: texData.useFlagsSimpleMode ? BoolProp(false, fileId: this.file.uuid) : null, - flag: texData.useFlagsSimpleMode ? null : HexProp(textures.isNotEmpty ? textures.last.flag!.value : 0, fileId: this.file.uuid), - ))); - file.onUndoableEvent(); - showToast("Added ${ddsFilesOrdered.length} DDS files"); + await file.textures!.patchFromFolder(paths); } } @@ -188,17 +161,18 @@ class _WtaWtpEditorState extends State { return Stack( children: [ TableEditor(config: _texturesTableConfig!), - // Positioned( - // bottom: 8, - // left: 8, - // width: 40, - // height: 40, - // child: FloatingActionButton( - // onPressed: _texturesTableConfig!.patchImportFolder, - // foregroundColor: getTheme(context).textColor, - // child: const Icon(Icons.create_new_folder), - // ), - // ) + Positioned( + bottom: 16, + left: 16, + width: 40, + height: 40, + child: FloatingActionButton( + onPressed: _texturesTableConfig!.patchFromFolder, + foregroundColor: getTheme(context).textColor, + tooltip: "Replace or add all from folder", + child: const Icon(Icons.folder_special), + ), + ) ], ); }